use std::sync::Arc;
use reqwest::StatusCode;
use reqwest_middleware::ClientWithMiddleware;
use serde::Serialize;
use thiserror::Error;
use url::Url;
use crate::challenge_middleware::{AuthFlow, AuthFlowError, BearerToken, Challenge};
const DEFAULT_MINT_PATH: &str = "/api/oidc/mint_token";
#[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: DEFAULT_MINT_PATH.to_string(),
}
}
pub fn for_host(server: &Url) -> Option<Self> {
Some(Self {
audience: server.host_str()?.to_string(),
mint_path: DEFAULT_MINT_PATH.to_string(),
})
}
pub fn for_server(server: &Url) -> Option<Self> {
let host = server.host_str()?;
if host == "prefix.dev" || host.ends_with(".prefix.dev") {
Some(Self::for_prefix_dev())
} else {
Self::for_host(server)
}
}
}
pub enum TrustedPublishResult {
Skipped,
Configured(BearerToken),
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),
}
#[deprecated(note = "use `rattler_networking::BearerToken` instead")]
pub type TrustedPublishingToken = BearerToken;
#[derive(Serialize)]
struct MintTokenRequest {
token: String,
}
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<BearerToken>, 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<BearerToken, 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(BearerToken::new(String::from_utf8_lossy(&body).to_string()))
} else {
Err(TrustedPublishingError::MintToken(
status,
String::from_utf8_lossy(&body).to_string(),
))
}
}
#[derive(Debug, Clone)]
pub struct TrustedPublishingFlow {
options: TrustedPublishingOptions,
client: ClientWithMiddleware,
}
impl TrustedPublishingFlow {
pub fn new(mut options: TrustedPublishingOptions, client: ClientWithMiddleware) -> Self {
if !options.mint_path.starts_with('/') {
options.mint_path.insert(0, '/');
}
Self { options, client }
}
pub fn for_prefix_dev(client: ClientWithMiddleware) -> Self {
Self::new(TrustedPublishingOptions::for_prefix_dev(), client)
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl AuthFlow for TrustedPublishingFlow {
async fn acquire_token(
&self,
url: &Url,
challenges: &[Challenge],
) -> Result<Option<BearerToken>, AuthFlowError> {
if !challenges
.iter()
.any(|challenge| challenge.scheme.eq_ignore_ascii_case("bearer"))
{
return Ok(None);
}
get_token(&self.client, url, &self.options)
.await
.map_err(AuthFlowError::new)
}
}
fn is_prefix_dev_host(host: &str) -> bool {
host == "prefix.dev" || host.ends_with(".prefix.dev")
}
#[derive(Debug, Clone)]
pub struct PrefixAuthAmbientFlow {
inner: Arc<dyn AuthFlow>,
}
impl PrefixAuthAmbientFlow {
pub fn new(client: ClientWithMiddleware) -> Self {
Self::wrapping(Arc::new(TrustedPublishingFlow::for_prefix_dev(client)))
}
pub fn wrapping(inner: Arc<dyn AuthFlow>) -> Self {
Self { inner }
}
}
impl Default for PrefixAuthAmbientFlow {
fn default() -> Self {
Self::new(reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build())
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl AuthFlow for PrefixAuthAmbientFlow {
async fn acquire_token(
&self,
url: &Url,
challenges: &[Challenge],
) -> Result<Option<BearerToken>, AuthFlowError> {
if url.scheme() != "https" || !url.host_str().is_some_and(is_prefix_dev_host) {
return Ok(None);
}
self.inner.acquire_token(url, challenges).await
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::challenge_middleware::{AuthFlow, Challenge};
fn bearer_challenge() -> Vec<Challenge> {
vec![Challenge {
scheme: "Bearer".to_string(),
params: HashMap::new(),
}]
}
fn plain_client() -> reqwest_middleware::ClientWithMiddleware {
reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()
}
#[tokio::test]
async fn flow_ignores_non_bearer_challenges() {
let flow = TrustedPublishingFlow::for_prefix_dev(plain_client());
let challenges = vec![Challenge {
scheme: "Basic".to_string(),
params: HashMap::new(),
}];
let result = flow
.acquire_token(
&Url::parse("https://prefix.dev/channel/repodata.json").unwrap(),
&challenges,
)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn flow_mints_token_via_gitlab_env() {
use axum::{Json, routing::post};
let router = axum::Router::new().route(
"/api/oidc/mint_token",
post(|Json(body): Json<serde_json::Value>| async move {
assert_eq!(body["token"], "fake.oidc.token");
"pfx-jwt.minted"
}),
);
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, router).await.unwrap() });
let server_url = Url::parse(&format!("http://{addr}")).unwrap();
let token = temp_env::async_with_vars(
[
("GITLAB_CI", Some("true")),
("PREFIX_DEV_ID_TOKEN", Some("fake.oidc.token")),
("GITHUB_ACTIONS", None),
("BUILDKITE", None),
("CIRCLECI", None),
],
async {
let flow = TrustedPublishingFlow::for_prefix_dev(plain_client());
flow.acquire_token(
&server_url.join("/channel/repodata.json").unwrap(),
&bearer_challenge(),
)
.await
.unwrap()
},
)
.await;
assert_eq!(
token.expect("expected a minted token").secret(),
"pfx-jwt.minted"
);
}
#[tokio::test]
async fn mint_path_without_leading_slash_is_normalized() {
use axum::routing::post;
let router = axum::Router::new().route("/api/x", post(|| async { "pfx-jwt.minted" }));
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, router).await.unwrap() });
let server_url = Url::parse(&format!("http://{addr}")).unwrap();
let token = temp_env::async_with_vars(
[
("GITLAB_CI", Some("true")),
("PREFIX_DEV_ID_TOKEN", Some("fake.oidc.token")),
("GITHUB_ACTIONS", None),
("BUILDKITE", None),
("CIRCLECI", None),
],
async {
let flow = TrustedPublishingFlow::new(
TrustedPublishingOptions {
audience: "prefix.dev".to_string(),
mint_path: "api/x".to_string(),
},
plain_client(),
);
flow.acquire_token(
&server_url.join("/channel/repodata.json").unwrap(),
&bearer_challenge(),
)
.await
.unwrap()
},
)
.await;
assert_eq!(
token.expect("expected a minted token").secret(),
"pfx-jwt.minted"
);
}
#[tokio::test]
async fn middleware_with_trusted_publishing_flow_end_to_end() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use axum::{
Json,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use crate::AuthChallengeMiddleware;
let mints = Arc::new(AtomicUsize::new(0));
let mints_in_handler = mints.clone();
let router = axum::Router::new()
.route(
"/channel/repodata.json",
get(|headers: axum::http::HeaderMap| async move {
match headers.get("authorization").and_then(|v| v.to_str().ok()) {
Some("Bearer pfx-jwt.minted") => (StatusCode::OK, "ok").into_response(),
_ => (
StatusCode::UNAUTHORIZED,
[("www-authenticate", r#"Bearer realm="test""#)],
"unauthorized",
)
.into_response(),
}
}),
)
.route(
"/api/oidc/mint_token",
post(move |Json(body): Json<serde_json::Value>| {
let mints = mints_in_handler.clone();
async move {
assert_eq!(body["token"], "fake.oidc.token");
mints.fetch_add(1, Ordering::SeqCst);
"pfx-jwt.minted"
}
}),
);
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, router).await.unwrap() });
let server_url = Url::parse(&format!("http://{addr}")).unwrap();
temp_env::async_with_vars(
[
("GITLAB_CI", Some("true")),
("PREFIX_DEV_ID_TOKEN", Some("fake.oidc.token")),
("GITHUB_ACTIONS", None),
("BUILDKITE", None),
("CIRCLECI", None),
],
async {
let flow = TrustedPublishingFlow::for_prefix_dev(plain_client());
let client = reqwest_middleware::ClientBuilder::new(reqwest::Client::new())
.with_arc(std::sync::Arc::new(AuthChallengeMiddleware::new(vec![
std::sync::Arc::new(flow),
])))
.build();
let url = server_url.join("/channel/repodata.json").unwrap();
assert_eq!(client.get(url.clone()).send().await.unwrap().status(), 200);
assert_eq!(client.get(url).send().await.unwrap().status(), 200);
},
)
.await;
assert_eq!(mints.load(Ordering::SeqCst), 1);
}
#[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 for_host_derives_audience_from_host() {
let options = TrustedPublishingOptions::for_host(
&Url::parse("https://beta.prefix.dev/some-channel/noarch/repodata.json").unwrap(),
)
.unwrap();
assert_eq!(options.audience, "beta.prefix.dev");
assert_eq!(options.mint_path, "/api/oidc/mint_token");
let prod =
TrustedPublishingOptions::for_host(&Url::parse("https://prefix.dev").unwrap()).unwrap();
assert_eq!(
prod.audience,
TrustedPublishingOptions::for_prefix_dev().audience
);
assert_eq!(
prod.mint_path,
TrustedPublishingOptions::for_prefix_dev().mint_path
);
}
#[test]
fn for_host_returns_none_without_host() {
let url = Url::parse("data:text/plain,hello").unwrap();
assert!(TrustedPublishingOptions::for_host(&url).is_none());
}
#[test]
fn for_host_normalizes_case_and_drops_default_port() {
let options = TrustedPublishingOptions::for_host(
&Url::parse("https://Beta.PREFIX.dev:443/some-channel").unwrap(),
)
.unwrap();
assert_eq!(options.audience, "beta.prefix.dev");
}
#[test]
fn for_server_uses_shared_audience_for_prefix_dev_family() {
let beta = TrustedPublishingOptions::for_server(
&Url::parse("https://beta.prefix.dev/some-channel").unwrap(),
)
.unwrap();
assert_eq!(beta.audience, "prefix.dev");
let prod = TrustedPublishingOptions::for_server(&Url::parse("https://prefix.dev").unwrap())
.unwrap();
assert_eq!(prod.audience, "prefix.dev");
let other = TrustedPublishingOptions::for_server(
&Url::parse("https://conda.example.com/channel").unwrap(),
)
.unwrap();
assert_eq!(other.audience, "conda.example.com");
let evil = TrustedPublishingOptions::for_server(
&Url::parse("https://evil-prefix.dev/channel").unwrap(),
)
.unwrap();
assert_eq!(evil.audience, "evil-prefix.dev");
}
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use crate::challenge_middleware::{AuthFlowError, BearerToken};
#[derive(Debug)]
struct SpyFlow {
calls: AtomicUsize,
}
#[async_trait::async_trait]
impl AuthFlow for SpyFlow {
async fn acquire_token(
&self,
_url: &Url,
_challenges: &[Challenge],
) -> Result<Option<BearerToken>, AuthFlowError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(Some(BearerToken::new("spy-token".to_string())))
}
}
#[tokio::test]
async fn ambient_flow_delegates_for_prefix_dev_family_hosts() {
let spy = Arc::new(SpyFlow {
calls: AtomicUsize::new(0),
});
let flow = PrefixAuthAmbientFlow::wrapping(spy.clone());
for host in ["prefix.dev", "beta.prefix.dev", "staging.beta.prefix.dev"] {
let url = Url::parse(&format!("https://{host}/channel/repodata.json")).unwrap();
let token = flow.acquire_token(&url, &bearer_challenge()).await.unwrap();
assert!(token.is_some(), "{host} should pass the trust gate");
}
assert_eq!(spy.calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn ambient_flow_never_delegates_for_untrusted_origins() {
let spy = Arc::new(SpyFlow {
calls: AtomicUsize::new(0),
});
let flow = PrefixAuthAmbientFlow::wrapping(spy.clone());
let challenges = vec![Challenge {
scheme: "Bearer".to_string(),
params: HashMap::from([("realm".to_string(), "prefix.dev".to_string())]),
}];
for url in [
"https://evil-prefix.dev/channel/repodata.json",
"https://prefix.dev.evil.com/channel/repodata.json",
"https://conda.anaconda.org/conda-forge/noarch/repodata.json",
"http://prefix.dev/channel/repodata.json", "https://beta.prefix.dev./channel/repodata.json", ] {
let url = Url::parse(url).unwrap();
let token = flow.acquire_token(&url, &challenges).await.unwrap();
assert!(token.is_none(), "{url} must be rejected by the trust gate");
}
assert_eq!(spy.calls.load(Ordering::SeqCst), 0);
}
}