use std::{
sync::{Arc, Mutex},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use base64::{
Engine as _,
engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD},
};
use reqwest::StatusCode;
use reqwest_middleware::{ClientWithMiddleware, Middleware, Next};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use url::Url;
const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub struct TrustedPublishingOptions {
pub audience: String,
pub mint_path: String,
}
impl TrustedPublishingOptions {
pub fn for_prefix_dev() -> Self {
Self {
audience: "prefix.dev".to_string(),
mint_path: "/api/oidc/mint_token".to_string(),
}
}
}
pub enum TrustedPublishResult {
Skipped,
Configured(TrustedPublishingToken),
Ignored(TrustedPublishingError),
}
#[derive(Debug, Error)]
pub enum TrustedPublishingError {
#[error(transparent)]
Url(#[from] url::ParseError),
#[error("Failed to fetch: `{0}`")]
Reqwest(Url, #[source] reqwest::Error),
#[error("Failed to fetch: `{0}`")]
ReqwestMiddleware(Url, #[source] reqwest_middleware::Error),
#[error(
"Server returned error code {0} from the mint endpoint, is trusted publishing correctly configured?\nResponse: {1}"
)]
MintToken(StatusCode, String),
#[error("Failed to retrieve an OIDC ID token from the CI provider")]
OidcToken(#[from] ambient_id::Error),
}
#[derive(Clone, Deserialize)]
#[serde(transparent)]
pub struct TrustedPublishingToken(String);
impl TrustedPublishingToken {
pub fn new(token: String) -> Self {
Self(token)
}
pub fn secret(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for TrustedPublishingToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("TrustedPublishingToken")
.field(&"<redacted>")
.finish()
}
}
#[derive(Serialize)]
struct MintTokenRequest {
token: String,
}
#[derive(Deserialize)]
struct JwtClaims {
exp: Option<u64>,
}
fn jwt_expiration(token: &str) -> Option<SystemTime> {
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
let _signature = parts.next()?;
if parts.next().is_some() {
return None;
}
let payload = URL_SAFE_NO_PAD
.decode(payload)
.or_else(|_| URL_SAFE.decode(payload))
.ok()?;
let claims: JwtClaims = serde_json::from_slice(&payload).ok()?;
claims
.exp
.and_then(|exp| UNIX_EPOCH.checked_add(Duration::from_secs(exp)))
}
pub async fn check_trusted_publishing(
client: &ClientWithMiddleware,
server_url: &Url,
options: &TrustedPublishingOptions,
) -> TrustedPublishResult {
match get_token(client, server_url, options).await {
Ok(Some(token)) => TrustedPublishResult::Configured(token),
Ok(None) => TrustedPublishResult::Skipped,
Err(err) => {
tracing::debug!("Could not obtain trusted publishing credentials, skipping: {err}");
TrustedPublishResult::Ignored(err)
}
}
}
pub async fn get_token(
client: &ClientWithMiddleware,
server_url: &Url,
options: &TrustedPublishingOptions,
) -> Result<Option<TrustedPublishingToken>, TrustedPublishingError> {
let detector = ambient_id::Detector::new_with_client(client.clone());
let Some(oidc_token) = detector.detect(&options.audience).await? else {
return Ok(None);
};
let publish_token = get_publish_token(&oidc_token, server_url, client, options).await?;
tracing::info!("Received OIDC token from CI provider, using trusted publishing");
Ok(Some(publish_token))
}
async fn get_publish_token(
oidc_token: &ambient_id::IdToken,
server_url: &Url,
client: &ClientWithMiddleware,
options: &TrustedPublishingOptions,
) -> Result<TrustedPublishingToken, TrustedPublishingError> {
let mint_token_url = server_url.join(&options.mint_path)?;
tracing::info!("Querying the trusted publishing token from {mint_token_url}");
let mint_token_payload = MintTokenRequest {
token: oidc_token.reveal().to_string(),
};
let response = client
.post(mint_token_url.clone())
.json(&mint_token_payload)
.send()
.await
.map_err(|err| TrustedPublishingError::ReqwestMiddleware(mint_token_url.clone(), err))?;
let status = response.status();
let body = response
.bytes()
.await
.map_err(|err| TrustedPublishingError::Reqwest(mint_token_url.clone(), err))?;
if status.is_success() {
Ok(TrustedPublishingToken(
String::from_utf8_lossy(&body).to_string(),
))
} else {
Err(TrustedPublishingError::MintToken(
status,
String::from_utf8_lossy(&body).to_string(),
))
}
}
#[derive(Clone, Debug)]
pub struct TrustedPublishingMiddleware {
channel_url: Url,
state: TrustedPublishingState,
}
#[derive(Clone, Debug)]
enum TrustedPublishingState {
Token(TrustedPublishingToken),
Lazy {
server_url: Url,
options: TrustedPublishingOptions,
client: ClientWithMiddleware,
cache: Arc<Mutex<TrustedPublishingCache>>,
},
}
#[derive(Debug, Default)]
enum TrustedPublishingCache {
#[default]
Empty,
Disabled,
Token(CachedTrustedPublishingToken),
}
#[derive(Clone, Debug)]
struct CachedTrustedPublishingToken {
token: TrustedPublishingToken,
expires_at: Option<SystemTime>,
}
impl CachedTrustedPublishingToken {
fn new(token: TrustedPublishingToken) -> Self {
let expires_at = jwt_expiration(token.secret());
Self { token, expires_at }
}
fn is_fresh(&self, now: SystemTime) -> bool {
self.expires_at
.is_none_or(|expires_at| now + TOKEN_REFRESH_MARGIN < expires_at)
}
}
impl TrustedPublishingMiddleware {
pub fn new(
server_url: Url,
options: TrustedPublishingOptions,
client: ClientWithMiddleware,
) -> Self {
let channel_url = normalize_channel_url(&server_url);
Self {
channel_url,
state: TrustedPublishingState::Lazy {
server_url,
options,
client,
cache: Arc::new(Mutex::new(TrustedPublishingCache::Empty)),
},
}
}
pub fn with_token(server_url: &Url, token: TrustedPublishingToken) -> Self {
Self {
channel_url: normalize_channel_url(server_url),
state: TrustedPublishingState::Token(token),
}
}
async fn token(&self) -> Option<TrustedPublishingToken> {
match &self.state {
TrustedPublishingState::Token(token) => Some(token.clone()),
TrustedPublishingState::Lazy {
server_url,
options,
client,
cache,
} => {
{
let cache = cache.lock().expect("trusted publishing cache poisoned");
match &*cache {
TrustedPublishingCache::Token(token)
if token.is_fresh(SystemTime::now()) =>
{
return Some(token.token.clone());
}
TrustedPublishingCache::Disabled => return None,
TrustedPublishingCache::Empty | TrustedPublishingCache::Token(_) => {}
}
}
let token = match check_trusted_publishing(client, server_url, options).await {
TrustedPublishResult::Configured(token) => Some(token),
TrustedPublishResult::Skipped => {
tracing::debug!(
"TrustedPublishingMiddleware: no CI provider detected, skipping OIDC token exchange"
);
None
}
TrustedPublishResult::Ignored(err) => {
tracing::warn!(
"TrustedPublishingMiddleware: trusted publishing failed: {err}"
);
None
}
};
let mut cache = cache.lock().expect("trusted publishing cache poisoned");
if let Some(token) = token {
let token = CachedTrustedPublishingToken::new(token);
let result = token.token.clone();
*cache = TrustedPublishingCache::Token(token);
Some(result)
} else {
*cache = TrustedPublishingCache::Disabled;
None
}
}
}
}
}
fn normalize_channel_url(url: &Url) -> Url {
let mut url = url.clone();
if !url.path().ends_with('/') {
let new_path = format!("{}/", url.path());
url.set_path(&new_path);
}
url
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl Middleware for TrustedPublishingMiddleware {
async fn handle(
&self,
mut req: reqwest::Request,
extensions: &mut http::Extensions,
next: Next<'_>,
) -> reqwest_middleware::Result<reqwest::Response> {
if req.headers().get(reqwest::header::AUTHORIZATION).is_none()
&& req.url().host_str() == self.channel_url.host_str()
&& req.url().path().starts_with(self.channel_url.path())
&& let Some(token) = self.token().await
{
let bearer_auth = format!("Bearer {}", token.secret());
let mut header_value = reqwest::header::HeaderValue::from_str(&bearer_auth)
.map_err(reqwest_middleware::Error::middleware)?;
header_value.set_sensitive(true);
req.headers_mut()
.insert(reqwest::header::AUTHORIZATION, header_value);
}
next.run(req, extensions).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn for_prefix_dev_matches_prefix_dev() {
let opts = TrustedPublishingOptions::for_prefix_dev();
assert_eq!(opts.audience, "prefix.dev");
assert_eq!(opts.mint_path, "/api/oidc/mint_token");
}
#[test]
fn token_debug_is_redacted() {
let token = TrustedPublishingToken::new("supersecret".to_string());
let formatted = format!("{token:?}");
assert!(!formatted.contains("supersecret"));
assert!(formatted.contains("redacted"));
}
fn unsigned_jwt_with_exp(exp: u64) -> String {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
let payload = URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
format!("{header}.{payload}.")
}
#[test]
fn jwt_expiration_reads_exp_claim() {
let token = unsigned_jwt_with_exp(1_700_000_000);
assert_eq!(
jwt_expiration(&token),
UNIX_EPOCH.checked_add(Duration::from_secs(1_700_000_000))
);
}
#[test]
fn cached_jwt_is_stale_inside_refresh_margin() {
let token = TrustedPublishingToken::new(unsigned_jwt_with_exp(1_700_000_000));
let cached = CachedTrustedPublishingToken::new(token);
let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000 - 30);
assert!(!cached.is_fresh(now));
}
#[tokio::test]
async fn middleware_injects_bearer_for_matching_host() {
use reqwest_middleware::ClientBuilder;
use std::sync::Arc;
let server = axum::Router::new().route(
"/check",
axum::routing::get(|headers: axum::http::HeaderMap| async move {
headers
.get("authorization")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default()
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, server).await.unwrap() });
let server_url = Url::parse(&format!("http://{addr}")).unwrap();
let token = TrustedPublishingToken::new("abc123".to_string());
let middleware = TrustedPublishingMiddleware::with_token(&server_url, token);
let client = ClientBuilder::new(reqwest::Client::new())
.with_arc(Arc::new(middleware))
.build();
let body = client
.get(server_url.join("/check").unwrap())
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "Bearer abc123");
}
#[tokio::test]
async fn middleware_skips_same_host_different_channel() {
use reqwest_middleware::ClientBuilder;
use std::sync::Arc;
let server = axum::Router::new()
.route(
"/my-channel/check",
axum::routing::get(|headers: axum::http::HeaderMap| async move {
if headers.contains_key("authorization") {
"has-auth".to_string()
} else {
"no-auth".to_string()
}
}),
)
.route(
"/other-channel/check",
axum::routing::get(|headers: axum::http::HeaderMap| async move {
if headers.contains_key("authorization") {
"has-auth".to_string()
} else {
"no-auth".to_string()
}
}),
)
.route(
"/my-channel-evil/check",
axum::routing::get(|headers: axum::http::HeaderMap| async move {
if headers.contains_key("authorization") {
"has-auth".to_string()
} else {
"no-auth".to_string()
}
}),
)
.route(
"/my-channel/subdir/check",
axum::routing::get(|headers: axum::http::HeaderMap| async move {
if headers.contains_key("authorization") {
"has-auth".to_string()
} else {
"no-auth".to_string()
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, server).await.unwrap() });
let channel_url = Url::parse(&format!("http://{addr}/my-channel/")).unwrap();
let token = TrustedPublishingToken::new("abc123".to_string());
let middleware = TrustedPublishingMiddleware::with_token(&channel_url, token);
let client = ClientBuilder::new(reqwest::Client::new())
.with_arc(Arc::new(middleware))
.build();
let body = client
.get(format!("http://{addr}/other-channel/check"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "no-auth");
let body = client
.get(format!("http://{addr}/my-channel-evil/check"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "no-auth");
let body = client
.get(format!("http://{addr}/my-channel/check"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "has-auth");
let body = client
.get(format!("http://{addr}/my-channel/subdir/check"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "has-auth");
}
#[test]
fn normalize_channel_url_adds_trailing_slash() {
let with_path = Url::parse("https://prefix.dev/my-channel").unwrap();
assert_eq!(normalize_channel_url(&with_path).path(), "/my-channel/");
let already_trailing = Url::parse("https://prefix.dev/my-channel/").unwrap();
assert_eq!(
normalize_channel_url(&already_trailing).path(),
"/my-channel/"
);
let host_only = Url::parse("https://prefix.dev").unwrap();
assert_eq!(host_only.path(), "/");
assert_eq!(normalize_channel_url(&host_only).path(), "/");
}
#[tokio::test]
async fn middleware_skips_non_matching_host() {
use reqwest_middleware::ClientBuilder;
use std::sync::Arc;
let server = axum::Router::new().route(
"/check",
axum::routing::get(|headers: axum::http::HeaderMap| async move {
if headers.contains_key("authorization") {
"has-auth".to_string()
} else {
"no-auth".to_string()
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, server).await.unwrap() });
let other_url = Url::parse("https://example.invalid").unwrap();
let token = TrustedPublishingToken::new("abc123".to_string());
let middleware = TrustedPublishingMiddleware::with_token(&other_url, token);
let client = ClientBuilder::new(reqwest::Client::new())
.with_arc(Arc::new(middleware))
.build();
let body = client
.get(format!("http://{addr}/check"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "no-auth");
}
}