use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use chrono::{DateTime, Utc};
use oauth2::basic::{BasicClient, BasicTokenResponse};
use oauth2::http::header::{ACCEPT, CONTENT_TYPE};
use oauth2::{
AsyncHttpClient, AuthType, ClientId, DeviceAuthorizationUrl, EndpointNotSet, EndpointSet,
ErrorResponse, HttpRequest, HttpResponse, RefreshToken, RequestTokenError, TokenResponse as _,
TokenUrl,
};
use secrecy::{ExposeSecret, SecretString};
use serde::Serialize;
use thiserror::Error;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use uuid::Uuid;
use wyrd_spec::auth::{
ExchangeTokenType, OAuthClientId, OAuthErrorCode, OAuthErrorResponse, SecretBearer,
TokenAudience, TokenRequest, TokenResponse, TokenType,
};
use wyrd_spec::error::WyrdError;
use wyrd_spec::ids::TenantSlug;
use wyrd_spec::operator_connection::HttpsOrigin;
use crate::config::{ClientConfig, TokenCacheMode};
use crate::credentials_file::CredentialsFile;
use crate::error::{WyrdClientError, from_problem_json};
use crate::transport::HttpConfig;
use crate::transport::credential::{AccessTokenSource, MintedAccessToken, ResolvedCredential};
use reqwest::Client;
use std::fmt::{Debug, Formatter, Result as FmtResult};
pub use oauth2::StandardDeviceAuthorizationResponse;
const REFRESH_SKEW_SECONDS: i64 = 30;
#[derive(Debug, Error)]
pub enum AuthError {
#[error(transparent)]
Client(#[from] WyrdClientError),
#[error(transparent)]
Server(WyrdError),
}
impl AuthError {
#[must_use]
pub fn into_wyrd(self) -> WyrdError {
match self {
Self::Server(wyrd) => wyrd,
Self::Client(client_error) => client_error.into(),
}
}
}
#[derive(Debug, Clone)]
struct CachedToken {
access_token: SecretBearer,
expires_at: DateTime<Utc>,
}
impl CachedToken {
fn is_stale(&self) -> bool {
Utc::now() >= self.expires_at - chrono::Duration::seconds(REFRESH_SKEW_SECONDS)
}
}
type PendingMint = JoinHandle<Result<MintedAccessToken, WyrdClientError>>;
type CliClient =
BasicClient<EndpointNotSet, EndpointSet, EndpointNotSet, EndpointNotSet, EndpointSet>;
#[derive(Clone)]
struct AuthHttp(
)` and the
Client,
);
impl AuthHttp {
async fn send(&self, request: HttpRequest) -> Result<HttpResponse, reqwest::Error> {
let mut response = self.0.execute(request.try_into()?).await?;
let status = response.status();
let headers = std::mem::take(response.headers_mut());
let mut buffered = HttpResponse::new(response.bytes().await?.to_vec());
*buffered.status_mut() = status;
*buffered.headers_mut() = headers;
Ok(buffered)
}
}
impl<'c> AsyncHttpClient<'c> for AuthHttp {
type Error = reqwest::Error;
type Future = Pin<Box<dyn Future<Output = Result<HttpResponse, reqwest::Error>> + Send + 'c>>;
fn call(&'c self, request: HttpRequest) -> Self::Future {
Box::pin(self.send(request))
}
}
#[derive(Clone)]
pub struct TokenExchange {
origin: HttpsOrigin,
http: AuthHttp,
oauth: CliClient,
}
impl Debug for TokenExchange {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("TokenExchange")
.field("origin", &self.origin)
.finish_non_exhaustive()
}
}
impl TokenExchange {
pub fn new(base_url: &str, timeout_ms: u64) -> Result<Self, WyrdClientError> {
let origin = HttpConfig {
base_url: base_url.to_owned(),
timeout_ms,
compression: false,
}
.validate()?;
wyrd_tls::install_crypto_provider().map_err(|error| WyrdClientError::TransportDown {
transport: "http".to_owned(),
message: error.to_string(),
})?;
let http = reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|err| WyrdClientError::TransportDown {
transport: "http".to_owned(),
message: format!("failed to build HTTP client: {err}"),
})?;
let endpoint = |path: &str| format!("{origin}{path}");
let invalid = |error: oauth2::url::ParseError| WyrdClientError::Config {
field: "http_config.base_url".to_owned(),
reason: error.to_string(),
};
let oauth = BasicClient::new(ClientId::new(OAuthClientId::WyrdCli.as_str().to_owned()))
.set_auth_type(AuthType::RequestBody)
.set_device_authorization_url(
DeviceAuthorizationUrl::new(endpoint("/auth/device_authorization"))
.map_err(invalid)?,
)
.set_token_uri(TokenUrl::new(endpoint("/auth/token")).map_err(invalid)?);
Ok(Self {
origin,
http: AuthHttp(http),
oauth,
})
}
#[must_use]
pub fn base_url(&self) -> &str {
self.origin.as_str()
}
pub async fn platform_session(
&self,
credential: &SecretBearer,
) -> Result<TokenResponse, AuthError> {
self.grant(
"/auth/platform/token",
&TokenRequest::TokenExchange {
subject_token: credential.clone(),
subject_token_type: ExchangeTokenType::ApiKey,
actor_token: None,
actor_token_type: None,
audience: None,
},
)
.await
}
pub async fn device_authorization(
&self,
tenant: &TenantSlug,
) -> Result<StandardDeviceAuthorizationResponse, AuthError> {
self.oauth
.exchange_device_code()
.add_extra_param("tenant", tenant.as_str())
.request_async(&self.http)
.await
.map_err(refused)
}
pub async fn device_access_token(
&self,
device: &StandardDeviceAuthorizationResponse,
) -> Result<TokenResponse, AuthError> {
self.oauth
.exchange_device_access_token(device)
.request_async(&self.http, tokio::time::sleep, None)
.await
.map(token_response)
.map_err(refused)
}
pub async fn refresh(&self, refresh_token: &SecretBearer) -> Result<TokenResponse, AuthError> {
self.oauth
.exchange_refresh_token(&RefreshToken::new(refresh_token.expose().to_owned()))
.request_async(&self.http)
.await
.map(token_response)
.map_err(refused)
}
pub async fn revoke_refresh_token(
&self,
refresh_token: &SecretBearer,
) -> Result<(), AuthError> {
self.post_form(
"/auth/revoke",
&RevocationForm {
token: refresh_token.expose(),
token_type_hint: "refresh_token",
},
)
.await
.map(drop)
}
async fn grant(&self, path: &str, request: &TokenRequest) -> Result<TokenResponse, AuthError> {
let response = self.post_form(path, request).await?;
serde_json::from_slice(response.body()).map_err(|error| decode_failure(&error))
}
async fn post_form<S: Serialize>(
&self,
path: &str,
params: &S,
) -> Result<HttpResponse, AuthError> {
let client_error = |reason: String| {
AuthError::Client(WyrdClientError::Config {
field: "token_request".to_owned(),
reason,
})
};
let form = serde_urlencoded::to_string(PublicClientForm {
client_id: OAuthClientId::WyrdCli,
params,
})
.map_err(|error| client_error(error.to_string()))?;
let request = oauth2::http::Request::post(format!("{}{path}", self.origin))
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(ACCEPT, "application/json")
.body(form.into_bytes())
.map_err(|error| client_error(error.to_string()))?;
let response = self.http.send(request).await.map_err(transport_down)?;
if !response.status().is_success() {
return Err(refusal(response.body()));
}
Ok(response)
}
}
#[derive(Serialize)]
struct RevocationForm<'a> {
token: &'a str,
token_type_hint: &'static str,
}
#[derive(Serialize)]
struct PublicClientForm<'a, S> {
client_id: OAuthClientId,
#[serde(flatten)]
params: &'a S,
}
fn token_response(token: BasicTokenResponse) -> TokenResponse {
TokenResponse {
access_token: SecretBearer::new(token.access_token().secret().to_owned()),
token_type: TokenType::Bearer,
expires_in: token.expires_in().map_or(0, |lifetime| lifetime.as_secs()),
refresh_token: token
.refresh_token()
.map(|refresh| SecretBearer::new(refresh.secret().to_owned())),
issued_token_type: None,
}
}
fn refused<T: ErrorResponse>(error: RequestTokenError<reqwest::Error, T>) -> AuthError {
match error {
RequestTokenError::ServerResponse(response) => match serde_json::to_vec(&response) {
Ok(body) => refusal(&body),
Err(error) => decode_failure(&error),
},
RequestTokenError::Request(error) => transport_down(error),
RequestTokenError::Parse(_, body) => refusal(&body),
RequestTokenError::Other(message) => AuthError::Client(WyrdClientError::TransportDown {
transport: "http".to_owned(),
message,
}),
}
}
fn refusal(body: &[u8]) -> AuthError {
match serde_json::from_slice::<serde_json::Value>(body) {
Ok(body) => AuthError::Server(
match serde_json::from_value::<OAuthErrorResponse>(body.clone()) {
Ok(error) => oauth_error(error),
Err(_) => from_problem_json(&body),
},
),
Err(error) => decode_failure(&error),
}
}
fn decode_failure(error: &serde_json::Error) -> AuthError {
AuthError::Client(WyrdClientError::TransportDown {
transport: "http".to_owned(),
message: format!("the auth response does not decode: {error}"),
})
}
fn oauth_error(error: OAuthErrorResponse) -> WyrdError {
let message = error
.error_description
.unwrap_or_else(|| "the authorization server refused the request".to_owned());
let details = serde_json::json!({ "error": error.error });
match error.error {
OAuthErrorCode::AuthorizationPending
| OAuthErrorCode::SlowDown
| OAuthErrorCode::AccessDenied
| OAuthErrorCode::ExpiredToken => WyrdError::DeviceAuthorization { message, details },
OAuthErrorCode::InvalidGrant => WyrdError::InvalidToken { message, details },
OAuthErrorCode::InvalidClient | OAuthErrorCode::UnauthorizedClient => {
WyrdError::Unauthenticated { message, details }
}
OAuthErrorCode::UnsupportedGrantType => {
WyrdError::UnsupportedGrantType { message, details }
}
OAuthErrorCode::InvalidRequest
| OAuthErrorCode::UnsupportedResponseType
| OAuthErrorCode::InvalidTarget => WyrdError::Validation { message, details },
OAuthErrorCode::TemporarilyUnavailable => {
WyrdError::AuthVerifyUnavailable { message, details }
}
OAuthErrorCode::ServerError => WyrdError::Internal { message, details },
}
}
pub(crate) fn expires_at(expires_in: u64) -> DateTime<Utc> {
i64::try_from(expires_in)
.ok()
.and_then(chrono::TimeDelta::try_seconds)
.and_then(|lifetime| Utc::now().checked_add_signed(lifetime))
.unwrap_or(DateTime::<Utc>::MAX_UTC)
}
pub struct AuthMiddleware {
credential: ResolvedCredential,
exchange: TokenExchange,
exchange_timeout: Duration,
cache: Mutex<Option<CachedToken>>,
pending_mint: Mutex<Option<PendingMint>>,
credentials: Option<CredentialsFile>,
short_ttl_warned: AtomicBool,
}
impl Debug for AuthMiddleware {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("AuthMiddleware")
.field("credential", &self.credential)
.field("http_base_url", &self.exchange.origin)
.field("credentials", &self.credentials)
.finish_non_exhaustive()
}
}
impl AuthMiddleware {
pub fn new(
config: &ClientConfig,
credential: ResolvedCredential,
) -> Result<Arc<Self>, WyrdClientError> {
let credentials = if config.token_cache == TokenCacheMode::Disk {
config.environment.config_dir().map(CredentialsFile::at)
} else {
None
};
Self::build(config, credential, credentials)
}
fn build(
config: &ClientConfig,
credential: ResolvedCredential,
credentials: Option<CredentialsFile>,
) -> Result<Arc<Self>, WyrdClientError> {
let exchange = TokenExchange::new(&config.http.base_url, config.http.timeout_ms)?;
let exchange_timeout = Duration::from_millis(config.http.timeout_ms);
let initial = match (&credentials, &credential) {
(Some(file), ResolvedCredential::ApiKey(api_key)) => file
.cached_api_key_token(api_key)
.map(|(access_token, expires_at)| CachedToken {
access_token,
expires_at,
})
.filter(|entry| !entry.is_stale()),
_ => None,
};
Ok(Arc::new(Self {
credential,
exchange,
exchange_timeout,
cache: Mutex::new(initial),
pending_mint: Mutex::new(None),
credentials,
short_ttl_warned: AtomicBool::new(false),
}))
}
#[cfg(test)]
fn new_with_credentials(
config: &ClientConfig,
credential: ResolvedCredential,
credentials: Option<CredentialsFile>,
) -> Result<Arc<Self>, WyrdClientError> {
Self::build(config, credential, credentials)
}
#[must_use]
pub fn on_behalf_of(
self: &Arc<Self>,
subject_token: SecretString,
audience: TokenAudience,
) -> Arc<Self> {
Arc::new(Self {
credential: ResolvedCredential::Delegated {
subject_token,
audience,
actor: Arc::clone(self),
},
exchange: self.exchange.clone(),
exchange_timeout: self.exchange_timeout,
cache: Mutex::new(None),
pending_mint: Mutex::new(None),
credentials: None,
short_ttl_warned: AtomicBool::new(false),
})
}
#[must_use]
pub fn credential(&self) -> &ResolvedCredential {
&self.credential
}
#[must_use]
pub fn base_url(&self) -> &str {
self.exchange.base_url()
}
#[must_use]
pub(crate) fn exchange_timeout(&self) -> Duration {
self.exchange_timeout
}
pub async fn bearer(&self) -> Result<SecretBearer, AuthError> {
match &self.credential {
ResolvedCredential::Renewable(source) => {
let mut cache = self.cache.lock().await;
if let Some(entry) = cache.as_ref()
&& !entry.is_stale()
{
return Ok(entry.access_token.clone());
}
self.mint_into(source, &mut cache).await
}
ResolvedCredential::BearerToken(token) => {
Ok(SecretBearer::new(token.expose_secret().to_owned()))
}
ResolvedCredential::WorkloadJwt { jwt, tenant } => {
let mut cache = self.cache.lock().await;
if let Some(entry) = cache.as_ref()
&& !entry.is_stale()
{
return Ok(entry.access_token.clone());
}
self.exchange_workload_and_store(jwt, tenant, &mut cache)
.await
}
ResolvedCredential::ApiKey(api_key) => {
let mut cache = self.cache.lock().await;
if let Some(entry) = cache.as_ref()
&& !entry.is_stale()
{
return Ok(entry.access_token.clone());
}
self.exchange_and_store(api_key, &mut cache).await
}
ResolvedCredential::Delegated {
subject_token,
audience,
actor,
} => {
let mut cache = self.cache.lock().await;
if let Some(entry) = cache.as_ref()
&& !entry.is_stale()
{
return Ok(entry.access_token.clone());
}
let entry = self
.exchange_delegated(subject_token, *audience, actor)
.await?;
Ok(self.store(entry, &mut cache))
}
}
}
pub async fn force_refresh(&self) -> Result<SecretBearer, AuthError> {
match &self.credential {
ResolvedCredential::Renewable(source) => {
let mut cache = self.cache.lock().await;
self.mint_into(source, &mut cache).await
}
ResolvedCredential::BearerToken(token) => {
Ok(SecretBearer::new(token.expose_secret().to_owned()))
}
ResolvedCredential::WorkloadJwt { jwt, tenant } => {
let mut cache = self.cache.lock().await;
self.exchange_workload_and_store(jwt, tenant, &mut cache)
.await
}
ResolvedCredential::ApiKey(api_key) => {
let mut cache = self.cache.lock().await;
self.exchange_and_store(api_key, &mut cache).await
}
ResolvedCredential::Delegated {
subject_token,
audience,
actor,
} => {
let mut cache = self.cache.lock().await;
let entry = self
.exchange_delegated(subject_token, *audience, actor)
.await?;
Ok(self.store(entry, &mut cache))
}
}
}
async fn exchange_and_store(
&self,
api_key: &SecretString,
cache: &mut Option<CachedToken>,
) -> Result<SecretBearer, AuthError> {
let entry = self.exchange(api_key).await?;
self.persist(api_key, &entry).await;
Ok(self.store(entry, cache))
}
async fn exchange_workload_and_store(
&self,
jwt: &SecretString,
tenant: &str,
cache: &mut Option<CachedToken>,
) -> Result<SecretBearer, AuthError> {
let entry = self.exchange_workload(jwt, tenant).await?;
Ok(self.store(entry, cache))
}
async fn mint_into(
&self,
source: &Arc<dyn AccessTokenSource>,
cache: &mut Option<CachedToken>,
) -> Result<SecretBearer, AuthError> {
let mut pending = self.pending_mint.lock().await;
let mint = pending.get_or_insert_with(|| {
let source = Arc::clone(source);
tokio::task::spawn_blocking(move || source.mint())
});
let joined = mint.await;
*pending = None;
let minted = joined.expect("renewable mint task joins")?;
let bearer = minted.access_token.clone();
*cache = Some(CachedToken {
access_token: minted.access_token,
expires_at: minted.expires_at,
});
Ok(bearer)
}
fn store(&self, entry: CachedToken, cache: &mut Option<CachedToken>) -> SecretBearer {
let bearer = entry.access_token.clone();
*cache = Some(entry);
bearer
}
#[must_use]
pub fn request_id(&self, inbound: Option<&str>) -> String {
let id = match inbound {
Some(value) if !value.is_empty() => value.to_owned(),
_ => Uuid::now_v7().to_string(),
};
tracing::trace!(target: "wyrd.client.request", wyrd_request_id = %id, "resolved wyrd-request-id");
id
}
async fn exchange(&self, api_key: &SecretString) -> Result<CachedToken, AuthError> {
let request = TokenRequest::TokenExchange {
subject_token: SecretBearer::new(api_key.expose_secret().to_owned()),
subject_token_type: ExchangeTokenType::ApiKey,
actor_token: None,
actor_token_type: None,
audience: None,
};
self.post_token_request(request).await
}
async fn exchange_workload(
&self,
jwt: &SecretString,
tenant: &str,
) -> Result<CachedToken, AuthError> {
let tenant = TenantSlug::new(tenant.to_owned()).map_err(|err| {
AuthError::Client(WyrdClientError::Config {
field: "tenant".to_owned(),
reason: format!("invalid workload tenant slug: {err}"),
})
})?;
let request = TokenRequest::JwtBearer {
assertion: SecretBearer::new(jwt.expose_secret().to_owned()),
tenant: Some(tenant),
};
self.post_token_request(request).await
}
async fn exchange_delegated(
&self,
subject_token: &SecretString,
audience: TokenAudience,
actor: &AuthMiddleware,
) -> Result<CachedToken, AuthError> {
let request = TokenRequest::TokenExchange {
subject_token: SecretBearer::new(subject_token.expose_secret().to_owned()),
subject_token_type: ExchangeTokenType::AccessToken,
actor_token: Some(Box::pin(actor.bearer()).await?),
actor_token_type: Some(ExchangeTokenType::AccessToken),
audience: Some(audience),
};
self.post_token_request(request).await
}
async fn post_token_request(&self, request: TokenRequest) -> Result<CachedToken, AuthError> {
let token = self.exchange.grant("/auth/token", &request).await?;
let expires_at = expires_at(token.expires_in);
self.warn_if_short_ttl(expires_at);
Ok(CachedToken {
access_token: token.access_token,
expires_at,
})
}
fn warn_if_short_ttl(&self, expires_at: DateTime<Utc>) {
let ttl = expires_at - Utc::now();
if ttl < chrono::Duration::seconds(2 * REFRESH_SKEW_SECONDS)
&& !self.short_ttl_warned.swap(true, Ordering::Relaxed)
{
tracing::warn!(
target: "wyrd.client.auth",
ttl_seconds = ttl.num_seconds(),
refresh_skew_seconds = REFRESH_SKEW_SECONDS,
"server-issued access TTL is below 2x the refresh skew; the token \
cache will refresh on nearly every call"
);
}
}
async fn persist(&self, api_key: &SecretString, entry: &CachedToken) {
let Some(file) = self.credentials.clone() else {
return;
};
let api_key = api_key.clone();
let access_token = entry.access_token.clone();
let expires_at = entry.expires_at;
let written = tokio::task::spawn_blocking(move || {
file.cache_api_key_token(&api_key, &access_token, expires_at)
})
.await;
if let Ok(Err(error)) = written {
tracing::debug!(target: "wyrd.client.auth", %error, "API key access token not cached");
}
}
}
fn transport_down(err: reqwest::Error) -> AuthError {
AuthError::Client(WyrdClientError::TransportDown {
transport: "http".to_owned(),
message: err.to_string(),
})
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use chrono::Utc;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::net::TcpListener;
use uuid::Uuid;
use super::{AuthError, AuthMiddleware, CachedToken, TokenExchange};
use crate::config::{ClientConfig, TokenCacheMode};
use crate::credentials_file::CredentialsFile;
use crate::error::WyrdClientError;
use crate::saved_login::{SavedLogin, SavedLogins};
use crate::transport::credential::{AccessTokenSource, MintedAccessToken, ResolvedCredential};
use wyrd_spec::auth::{SecretBearer, TokenRequest};
use wyrd_spec::ids::TenantSlug;
struct MockServer {
base_url: String,
hits: Arc<AtomicUsize>,
_handle: tokio::task::JoinHandle<()>,
}
async fn spawn_mock(status_line: &str, body: String) -> MockServer {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
let hits = Arc::new(AtomicUsize::new(0));
let hits_loop = hits.clone();
let response = format!(
"{status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body,
);
let handle = tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
hits_loop.fetch_add(1, Ordering::SeqCst);
let response = response.clone();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let _ = stream.read(&mut buf).await;
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
});
}
});
MockServer {
base_url: format!("http://{addr}"),
hits,
_handle: handle,
}
}
fn token_body(access: &str, expires_in_secs: i64) -> String {
serde_json::json!({
"access_token": access,
"refresh_token": "refresh-should-drop",
"token_type": "Bearer",
"expires_in": expires_in_secs,
})
.to_string()
}
fn config_for(base_url: String, cache: TokenCacheMode) -> ClientConfig {
let mut config = ClientConfig::default();
config.http.base_url = base_url;
config.token_cache = cache;
config
}
fn api_key_credential() -> ResolvedCredential {
ResolvedCredential::ApiKey("api-key-value".to_owned().into())
}
fn workload_jwt_credential(tenant: &str) -> ResolvedCredential {
ResolvedCredential::WorkloadJwt {
jwt: "workload-oidc-assertion".to_owned().into(),
tenant: tenant.to_owned(),
}
}
#[tokio::test]
async fn exchange_caches_access_token_and_drops_refresh() {
let mock = spawn_mock("HTTP/1.1 200 OK", token_body("access-123", 3600)).await;
let mw = AuthMiddleware::new(
&config_for(mock.base_url.clone(), TokenCacheMode::InMemory),
api_key_credential(),
)
.expect("middleware builds");
let bearer = mw.bearer().await.expect("exchange succeeds");
assert_eq!(bearer.expose(), "access-123");
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
let again = mw.bearer().await.expect("cached");
assert_eq!(again.expose(), "access-123");
assert_eq!(
mock.hits.load(Ordering::SeqCst),
1,
"fresh token must be served from cache"
);
}
#[test]
fn is_stale_true_at_and_within_skew_boundary() {
let inside = CachedToken {
access_token: SecretBearer::new("x".to_owned()),
expires_at: Utc::now() + chrono::Duration::seconds(20),
};
assert!(inside.is_stale(), "20s < 30s skew must be stale");
let boundary = CachedToken {
access_token: SecretBearer::new("x".to_owned()),
expires_at: Utc::now() + chrono::Duration::seconds(30),
};
assert!(boundary.is_stale(), "exactly at 30s skew boundary is stale");
}
#[test]
fn is_stale_false_outside_skew() {
let fresh = CachedToken {
access_token: SecretBearer::new("x".to_owned()),
expires_at: Utc::now() + chrono::Duration::seconds(120),
};
assert!(!fresh.is_stale());
}
#[tokio::test]
async fn force_refresh_re_exchanges_once() {
let mock = spawn_mock("HTTP/1.1 200 OK", token_body("access-1", 3600)).await;
let mw = AuthMiddleware::new(
&config_for(mock.base_url.clone(), TokenCacheMode::InMemory),
api_key_credential(),
)
.expect("middleware builds");
let _ = mw.bearer().await.expect("first exchange");
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
let _ = mw.force_refresh().await.expect("forced re-exchange");
assert_eq!(
mock.hits.load(Ordering::SeqCst),
2,
"force_refresh must perform exactly one additional exchange"
);
}
#[tokio::test]
async fn on_behalf_of_caches_the_exchange_and_redacts_the_subject() {
let mock = spawn_mock("HTTP/1.1 200 OK", token_body("delegated-1", 3600)).await;
let actor = AuthMiddleware::new(
&config_for(mock.base_url.clone(), TokenCacheMode::InMemory),
api_key_credential(),
)
.expect("middleware builds");
let delegated = actor.on_behalf_of(
"subject-secret".to_owned().into(),
wyrd_spec::auth::TokenAudience::Bifrost,
);
let bearer = delegated.bearer().await.expect("delegated exchange");
assert_eq!(bearer.expose(), "delegated-1");
assert_eq!(mock.hits.load(Ordering::SeqCst), 2, "actor then exchange");
delegated.bearer().await.expect("cached");
assert_eq!(mock.hits.load(Ordering::SeqCst), 2, "served from cache");
delegated.force_refresh().await.expect("forced re-exchange");
assert_eq!(
mock.hits.load(Ordering::SeqCst),
3,
"only the delegated exchange repeats"
);
assert!(delegated.credentials.is_none(), "never persisted to disk");
let debug = format!("{delegated:?}");
assert!(!debug.contains("subject-secret"), "{debug}");
assert!(debug.contains("Bifrost"), "{debug}");
}
#[tokio::test]
async fn stale_token_re_exchanges_without_error() {
let mock = spawn_mock("HTTP/1.1 200 OK", token_body("access-stale", 5)).await;
let mw = AuthMiddleware::new(
&config_for(mock.base_url.clone(), TokenCacheMode::InMemory),
api_key_credential(),
)
.expect("middleware builds");
let first = mw.bearer().await.expect("first exchange ok");
let second = mw.bearer().await.expect("stale token never errors");
assert_eq!(first.expose(), second.expose());
assert_eq!(
mock.hits.load(Ordering::SeqCst),
2,
"a stale cached token must trigger a fresh exchange, not an error"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_bearer_callers_single_flight() {
let mock = spawn_mock("HTTP/1.1 200 OK", token_body("access-shared", 3600)).await;
let mw = AuthMiddleware::new(
&config_for(mock.base_url.clone(), TokenCacheMode::InMemory),
api_key_credential(),
)
.expect("middleware builds");
let mut handles = Vec::new();
for _ in 0..16 {
let mw = mw.clone();
handles.push(tokio::spawn(async move {
mw.bearer()
.await
.expect("concurrent bearer ok")
.expose()
.to_owned()
}));
}
for handle in handles {
assert_eq!(handle.await.expect("task joins"), "access-shared");
}
assert_eq!(
mock.hits.load(Ordering::SeqCst),
1,
"N concurrent callers on an empty cache must trigger exactly one exchange"
);
}
#[tokio::test]
async fn revoked_key_maps_to_wyrd_error() {
let body = serde_json::json!({
"type": "https://wyrd.dev/problems/WYRD_AUTH_401_API_KEY_INVALID",
"title": "API key not found, revoked, or hash mismatch",
"status": 401,
"code": "WYRD_AUTH_401_API_KEY_INVALID",
"detail": "API key revoked",
"details": {},
})
.to_string();
let mock = spawn_mock("HTTP/1.1 401 Unauthorized", body).await;
let mw = AuthMiddleware::new(
&config_for(mock.base_url.clone(), TokenCacheMode::InMemory),
api_key_credential(),
)
.expect("middleware builds");
let err = mw.bearer().await.expect_err("revoked key must fail");
match err {
AuthError::Server(wyrd) => {
assert_eq!(
wyrd.code(),
"WYRD_AUTH_401_API_KEY_INVALID",
"the revoked-key code must be preserved in the mapped WyrdError"
);
assert_eq!(
wyrd.status(),
401,
"revoked-key status must be 401, not the 502 catch-all"
);
}
AuthError::Client(other) => panic!("expected server error, got {other:?}"),
}
}
#[tokio::test]
async fn workload_exchange_caches_and_serves_from_shared_cache() {
let mock = spawn_mock("HTTP/1.1 200 OK", token_body("workload-access", 3600)).await;
let mw = AuthMiddleware::new(
&config_for(mock.base_url.clone(), TokenCacheMode::InMemory),
workload_jwt_credential("acme"),
)
.expect("middleware builds");
let bearer = mw.bearer().await.expect("workload exchange succeeds");
assert_eq!(bearer.expose(), "workload-access");
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
let again = mw.bearer().await.expect("cached");
assert_eq!(again.expose(), "workload-access");
assert_eq!(
mock.hits.load(Ordering::SeqCst),
1,
"a fresh workload token must be served from cache, not re-exchanged"
);
}
#[tokio::test]
async fn workload_force_refresh_re_exchanges_once() {
let mock = spawn_mock("HTTP/1.1 200 OK", token_body("workload-1", 3600)).await;
let mw = AuthMiddleware::new(
&config_for(mock.base_url.clone(), TokenCacheMode::InMemory),
workload_jwt_credential("acme"),
)
.expect("middleware builds");
let _ = mw.bearer().await.expect("first workload exchange");
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
let _ = mw
.force_refresh()
.await
.expect("forced workload re-exchange");
assert_eq!(
mock.hits.load(Ordering::SeqCst),
2,
"force_refresh must perform exactly one additional workload exchange"
);
}
#[tokio::test]
async fn workload_invalid_tenant_slug_is_client_error_preflight() {
let mock = spawn_mock("HTTP/1.1 200 OK", token_body("never-issued", 3600)).await;
let mw = AuthMiddleware::new(
&config_for(mock.base_url.clone(), TokenCacheMode::InMemory),
workload_jwt_credential("INVALID SLUG!"),
)
.expect("middleware builds");
let err = mw
.bearer()
.await
.expect_err("invalid tenant slug must fail");
match err {
AuthError::Client(WyrdClientError::Config { field, .. }) => {
assert_eq!(field, "tenant");
}
other => panic!("expected client-local config error, got {other:?}"),
}
assert_eq!(
mock.hits.load(Ordering::SeqCst),
0,
"an invalid tenant slug must be rejected before any network call"
);
}
#[tokio::test]
async fn workload_server_rejection_maps_to_server_error() {
let body = serde_json::json!({
"type": "https://wyrd.dev/problems/WYRD_AUTH_401_INVALID_TOKEN",
"title": "Token rejected",
"status": 401,
"code": "WYRD_AUTH_401_INVALID_TOKEN",
"detail": "workload assertion rejected",
"details": {},
})
.to_string();
let mock = spawn_mock("HTTP/1.1 401 Unauthorized", body).await;
let mw = AuthMiddleware::new(
&config_for(mock.base_url.clone(), TokenCacheMode::InMemory),
workload_jwt_credential("acme"),
)
.expect("middleware builds");
let err = mw
.bearer()
.await
.expect_err("server rejection of workload assertion must fail");
match err {
AuthError::Server(_) => {}
AuthError::Client(other) => panic!("expected server error, got {other:?}"),
}
}
#[test]
fn cached_token_debug_redacts_secret() {
let entry = CachedToken {
access_token: SecretBearer::new("top-secret-token".to_owned()),
expires_at: Utc::now(),
};
let debug = format!("{entry:?}");
assert!(debug.contains("REDACTED"));
assert!(!debug.contains("top-secret-token"));
}
fn credentials_dir() -> tempfile::TempDir {
let dir = crate::credentials_file::private_tempdir();
let path = dir.path().join("credentials.toml");
std::fs::write(
&path,
"# kept by the user\n[default]\napi_key = \"api-key-value\"\n",
)
.expect("write credentials");
#[cfg(unix)]
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.expect("chmod credentials");
dir
}
#[tokio::test]
async fn disk_cache_writes_token_beside_its_api_key() {
let dir = credentials_dir();
let path = dir.path().join("credentials.toml");
let mock = spawn_mock("HTTP/1.1 200 OK", token_body("disk-access", 3600)).await;
let config = config_for(mock.base_url.clone(), TokenCacheMode::Disk);
let file = || Some(CredentialsFile::at(dir.path().to_path_buf()));
let mw = AuthMiddleware::new_with_credentials(&config, api_key_credential(), file())
.expect("middleware builds");
mw.bearer().await.expect("exchange ok");
#[cfg(unix)]
{
let mode = std::fs::metadata(&path)
.expect("credentials")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "credentials.toml must stay 0600");
}
let contents = std::fs::read_to_string(&path).expect("read credentials");
assert!(contents.contains("# kept by the user"), "{contents}");
assert!(contents.contains("api_key = \"api-key-value\""));
assert!(contents.contains("access_token = \"disk-access\""));
assert!(contents.contains("access_expires_at"));
assert!(
!contents.contains("refresh-should-drop"),
"refresh token never written"
);
assert!(
!dir.path().join("tokens").exists(),
"no separate token cache"
);
let reused = AuthMiddleware::new_with_credentials(&config, api_key_credential(), file())
.expect("middleware builds");
reused.bearer().await.expect("cached bearer");
assert_eq!(
mock.hits.load(Ordering::SeqCst),
1,
"served from credentials.toml"
);
let other = AuthMiddleware::new_with_credentials(
&config,
ResolvedCredential::ApiKey("other-key".to_owned().into()),
file(),
)
.expect("middleware builds");
other.bearer().await.expect("other key exchanges");
assert_eq!(
mock.hits.load(Ordering::SeqCst),
2,
"another key never reuses it"
);
assert_eq!(
std::fs::read_to_string(&path).expect("read credentials"),
contents,
"another key never writes the cache"
);
}
struct CountingSource {
minted: AtomicUsize,
ttl_seconds: i64,
}
impl CountingSource {
fn new(ttl_seconds: i64) -> Arc<Self> {
Arc::new(Self {
minted: AtomicUsize::new(0),
ttl_seconds,
})
}
}
impl AccessTokenSource for CountingSource {
fn identity(&self) -> &'static str {
"test-system-producer"
}
fn mint(&self) -> Result<MintedAccessToken, WyrdClientError> {
let ordinal = self.minted.fetch_add(1, Ordering::SeqCst) + 1;
Ok(MintedAccessToken {
access_token: SecretBearer::new(format!("minted-{ordinal}")),
expires_at: Utc::now() + chrono::Duration::seconds(self.ttl_seconds),
})
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn renewable_source_uses_the_shared_refresh_lifecycle() {
let config = config_for("http://127.0.0.1:9".to_owned(), TokenCacheMode::InMemory);
let fresh = CountingSource::new(900);
let mw = AuthMiddleware::new(&config, ResolvedCredential::Renewable(fresh.clone()))
.expect("middleware builds");
let mut readers = Vec::new();
for _ in 0..16 {
let mw = Arc::clone(&mw);
readers.push(tokio::spawn(async move {
mw.bearer().await.expect("mint").expose().to_owned()
}));
}
for reader in readers {
assert_eq!(reader.await.expect("reader joins"), "minted-1");
}
assert_eq!(fresh.minted.load(Ordering::SeqCst), 1, "single-flight mint");
assert_eq!(
mw.force_refresh().await.expect("forced mint").expose(),
"minted-2"
);
assert_eq!(mw.bearer().await.expect("cached").expose(), "minted-2");
let stale = CountingSource::new(5);
let mw = AuthMiddleware::new(&config, ResolvedCredential::Renewable(stale.clone()))
.expect("middleware builds");
mw.bearer().await.expect("first mint");
assert_eq!(
mw.bearer().await.expect("proactive refresh").expose(),
"minted-2",
"a token inside the refresh skew is replaced before use"
);
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn saved_login_token_is_reused_until_expiry() {
let dir = crate::credentials_file::private_tempdir();
let origin = "http://127.0.0.1:9";
let login = SavedLogin {
origin: origin.to_owned(),
tenant_key: "acme".parse::<TenantSlug>().expect("slug"),
access_token: SecretBearer::new("first".to_owned()),
access_expires_at: Utc::now() + chrono::Duration::hours(1),
refresh_token: SecretBearer::new("refresh".to_owned()),
};
let store = SavedLogins::at(dir.path().to_path_buf());
store.save(login.clone()).expect("saves");
let source = || {
let exchange = TokenExchange::new(origin, 1_000).expect("exchange builds");
AuthMiddleware::new(
&config_for(origin.to_owned(), TokenCacheMode::InMemory),
ResolvedCredential::Renewable(store.renewing_source(&login, exchange)),
)
.expect("middleware builds")
};
let refused = |result: Result<SecretBearer, AuthError>| match result {
Err(AuthError::Client(error)) => error.to_string(),
other => panic!("the renewal is refused, got {other:?}"),
};
assert_eq!(source().bearer().await.expect("saved").expose(), "first");
let expired = SavedLogin {
access_expires_at: Utc::now(),
..login.clone()
};
store.save(expired.clone()).expect("saves");
let error = refused(source().bearer().await);
assert!(
!error.contains("saved user login"),
"transport error: {error}"
);
assert_eq!(store.list().expect("lists"), vec![expired]);
std::fs::set_permissions(
dir.path().join("credentials.toml"),
std::fs::Permissions::from_mode(0o644),
)
.expect("chmod");
assert!(refused(source().bearer().await).contains("(unsafe_store)"));
std::fs::set_permissions(
dir.path().join("credentials.toml"),
std::fs::Permissions::from_mode(0o600),
)
.expect("chmod");
store
.remove(origin, &login.tenant_key)
.expect("removes")
.expect("was saved");
assert!(refused(source().bearer().await).contains("(logged_out)"));
}
#[tokio::test]
async fn renewable_tokens_are_never_persisted() {
let dir = credentials_dir();
let before = std::fs::read_to_string(dir.path().join("credentials.toml")).expect("read");
let mw = AuthMiddleware::new_with_credentials(
&config_for("http://127.0.0.1:9".to_owned(), TokenCacheMode::Disk),
ResolvedCredential::Renewable(CountingSource::new(900)),
Some(CredentialsFile::at(dir.path().to_path_buf())),
)
.expect("middleware builds");
mw.bearer().await.expect("mint");
let after = std::fs::read_to_string(dir.path().join("credentials.toml")).expect("read");
assert_eq!(before, after, "a minted capture token must not reach disk");
assert!(format!("{mw:?}").contains("test-system-producer"));
}
#[test]
fn token_exchange_refuses_remote_cleartext() {
for remote in [
"http://wyrd.example.com",
"http://10.0.0.5:8080/",
"HTTP://wyrd.example.com",
"Http://10.0.0.5:8080/",
] {
let error = TokenExchange::new(remote, 30_000).expect_err("remote http is refused");
assert!(
matches!(&error, WyrdClientError::Config { reason, .. } if reason.contains("cleartext")),
"{error:?}"
);
}
for allowed in [
"https://wyrd.example.com",
"http://localhost:8080",
"http://127.0.0.1:9000/",
"http://[::1]:8080",
"HTTPS://Wyrd.Example.com",
] {
TokenExchange::new(allowed, 30_000).expect("allowed target builds");
}
}
#[tokio::test]
async fn token_exchange_never_follows_a_redirect() {
let target = spawn_mock("HTTP/1.1 200 OK", token_body("stolen", 3600)).await;
for status in ["307 Temporary Redirect", "308 Permanent Redirect"] {
let redirect = spawn_mock(
&format!(
"HTTP/1.1 {status}\r\nlocation: {}/auth/token",
target.base_url
),
String::new(),
)
.await;
let exchange = TokenExchange::new(&redirect.base_url, 30_000).expect("builds");
exchange
.refresh(&SecretBearer::new("refresh-secret".to_owned()))
.await
.expect_err("a redirected refresh fails");
exchange
.grant(
"/auth/token",
&TokenRequest::JwtBearer {
assertion: SecretBearer::new("workload-secret".to_owned()),
tenant: None,
},
)
.await
.expect_err("a redirected exchange fails");
exchange
.revoke_refresh_token(&SecretBearer::new("refresh-secret".to_owned()))
.await
.expect_err("a redirected revocation fails");
assert_eq!(redirect.hits.load(Ordering::SeqCst), 3);
}
assert_eq!(
target.hits.load(Ordering::SeqCst),
0,
"no body was replayed"
);
}
#[test]
fn request_id_forwards_supplied_id() {
let mw = AuthMiddleware::new(&ClientConfig::default(), api_key_credential())
.expect("middleware builds");
assert_eq!(mw.request_id(Some("inbound-abc")), "inbound-abc");
assert_ne!(mw.request_id(Some("")), "");
}
#[test]
fn request_id_mints_uuid_v7_when_absent() {
let mw = AuthMiddleware::new(&ClientConfig::default(), api_key_credential())
.expect("middleware builds");
let id = mw.request_id(None);
let parsed = Uuid::parse_str(&id).expect("minted id is a valid uuid");
assert_eq!(
parsed.get_version_num(),
7,
"minted request id must be UUIDv7"
);
}
}