use crate::credential::Credential;
use crate::provide_credential::entra::{
EntraTokenResponse, parse_token_response, token_request_error,
};
use reqsign_core::time::Timestamp;
use reqsign_core::{Context, ProvideCredential};
use serde::Deserialize;
use std::collections::HashMap;
use std::time::Duration;
#[derive(Clone, Debug, Default)]
pub struct AzurePipelinesCredentialProvider {
tenant_id: Option<String>,
client_id: Option<String>,
service_connection_id: Option<String>,
}
impl AzurePipelinesCredentialProvider {
pub fn new() -> Self {
Self::default()
}
pub fn with_tenant_id(mut self, tenant_id: &str) -> Self {
self.tenant_id = Some(tenant_id.to_string());
self
}
pub fn with_client_id(mut self, client_id: &str) -> Self {
self.client_id = Some(client_id.to_string());
self
}
pub fn with_service_connection_id(mut self, service_connection_id: &str) -> Self {
self.service_connection_id = Some(service_connection_id.to_string());
self
}
async fn get_oidc_token(
&self,
ctx: &Context,
oidc_request_uri: &str,
system_access_token: &str,
service_connection_id: &str,
) -> Result<String, reqsign_core::Error> {
let url = format!(
"{oidc_request_uri}?api-version=7.1&serviceConnectionId={service_connection_id}"
);
let req = http::Request::builder()
.method(http::Method::POST)
.uri(url)
.header("Authorization", format!("Bearer {system_access_token}"))
.header("Content-Type", "application/json")
.header("Content-Length", "0")
.body(bytes::Bytes::new())
.map_err(|e| {
reqsign_core::Error::unexpected(format!("Failed to build OIDC request: {e}"))
})?;
let resp = ctx.http_send(req).await?;
if resp.status() != http::StatusCode::OK {
return Err(token_request_error(
"Azure Pipelines OIDC token request",
resp.status(),
));
}
let body = resp.into_body();
let oidc_response: OidcTokenResponse = serde_json::from_slice(&body).map_err(|e| {
reqsign_core::Error::unexpected(format!("Failed to parse OIDC response: {e}"))
})?;
Ok(oidc_response.oidc_token)
}
async fn exchange_token(
&self,
ctx: &Context,
tenant_id: &str,
client_id: &str,
oidc_token: &str,
) -> Result<EntraTokenResponse, reqsign_core::Error> {
let url = format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token");
let mut params = HashMap::new();
params.insert("scope", "https://storage.azure.com/.default");
params.insert("client_id", client_id);
params.insert(
"client_assertion_type",
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
);
params.insert("client_assertion", oidc_token);
params.insert("grant_type", "client_credentials");
let body = form_urlencoded::Serializer::new(String::new())
.extend_pairs(params)
.finish();
let req = http::Request::builder()
.method(http::Method::POST)
.uri(url)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(bytes::Bytes::from(body))
.map_err(|e| {
reqsign_core::Error::unexpected(format!("Failed to build token request: {e}"))
})?;
let resp = ctx.http_send(req).await?;
if resp.status() != http::StatusCode::OK {
return Err(token_request_error(
"Microsoft Entra Azure Pipelines token request",
resp.status(),
));
}
parse_token_response(resp.body(), "Azure Pipelines token response")
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OidcTokenResponse {
oidc_token: String,
}
impl ProvideCredential for AzurePipelinesCredentialProvider {
type Credential = Credential;
async fn provide_credential(
&self,
ctx: &Context,
) -> Result<Option<Self::Credential>, reqsign_core::Error> {
let envs = ctx.env_vars();
let system_access_token = match envs.get("SYSTEM_ACCESSTOKEN") {
Some(token) => token,
None => return Ok(None), };
let oidc_request_uri = match envs.get("SYSTEM_OIDCREQUESTURI") {
Some(uri) => uri,
None => return Ok(None), };
let tenant_id = self
.tenant_id
.as_ref()
.or_else(|| envs.get("ARM_TENANT_ID"))
.or_else(|| envs.get("AZURE_TENANT_ID"))
.cloned();
let client_id = self
.client_id
.as_ref()
.or_else(|| envs.get("ARM_CLIENT_ID"))
.or_else(|| envs.get("AZURE_CLIENT_ID"))
.cloned();
let service_connection_id = self
.service_connection_id
.as_ref()
.or_else(|| envs.get("ARM_OIDC_AZURE_SERVICE_CONNECTION_ID"))
.or_else(|| envs.get("AZURE_SERVICE_CONNECTION_ID"))
.filter(|id| !id.is_empty());
let (tenant_id, client_id, service_connection_id) =
match (tenant_id, client_id, service_connection_id) {
(Some(t), Some(c), Some(s)) => (t, c, s),
_ => return Ok(None),
};
let oidc_token = self
.get_oidc_token(
ctx,
oidc_request_uri,
system_access_token,
service_connection_id,
)
.await?;
let token_response = self
.exchange_token(ctx, &tenant_id, &client_id, &oidc_token)
.await?;
let expires_in = Duration::from_secs(token_response.expires_in);
let expires_on = Timestamp::now() + expires_in;
Ok(Some(Credential::with_bearer_token(
&token_response.access_token,
Some(expires_on),
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_configuration() {
let provider = AzurePipelinesCredentialProvider::new()
.with_tenant_id("test-tenant")
.with_client_id("test-client")
.with_service_connection_id("test-connection");
assert_eq!(provider.tenant_id, Some("test-tenant".to_string()));
assert_eq!(provider.client_id, Some("test-client".to_string()));
assert_eq!(
provider.service_connection_id,
Some("test-connection".to_string())
);
}
#[tokio::test]
async fn test_provide_credential_no_environment() {
let provider = AzurePipelinesCredentialProvider::new();
let ctx = Context::new()
.with_file_read(reqsign_file_read_tokio::TokioFileRead)
.with_http_send(reqsign_http_send_reqwest::ReqwestHttpSend::default());
let result = provider.provide_credential(&ctx).await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
}