use crate::Credential;
use crate::provide_credential::entra::{
EntraTokenResponse, parse_token_response, token_request_error,
};
use reqsign_core::time::Timestamp;
use reqsign_core::{Context, Error, ProvideCredential, Result};
use std::fmt::{Debug, Formatter};
use std::time::Duration;
#[derive(Clone)]
struct LoadedSubjectToken {
token: String,
expires_at: Option<Timestamp>,
}
impl LoadedSubjectToken {
fn new(token: impl Into<String>, expires_at: Option<Timestamp>) -> Self {
Self {
token: token.into(),
expires_at,
}
}
fn validate(&self) -> Result<()> {
if self.token.trim().is_empty() {
return Err(Error::credential_invalid("subject token is empty"));
}
if self
.expires_at
.is_some_and(|expires_at| expires_at <= Timestamp::now())
{
return Err(Error::credential_invalid("subject token has expired"));
}
Ok(())
}
}
#[derive(Clone, Default)]
enum FederatedTokenSource {
#[default]
Environment,
File(String),
Direct(LoadedSubjectToken),
}
#[derive(Default, Clone)]
pub struct WorkloadIdentityCredentialProvider {
tenant_id: Option<String>,
client_id: Option<String>,
token_source: FederatedTokenSource,
authority_host: Option<String>,
}
impl Debug for WorkloadIdentityCredentialProvider {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let token_source = match &self.token_source {
FederatedTokenSource::Environment => "environment",
FederatedTokenSource::File(_) => "file",
FederatedTokenSource::Direct(_) => "direct",
};
f.debug_struct("WorkloadIdentityCredentialProvider")
.field("tenant_id", &self.tenant_id)
.field("client_id", &self.client_id)
.field("token_source", &token_source)
.field("authority_host", &self.authority_host)
.finish()
}
}
impl WorkloadIdentityCredentialProvider {
pub fn new() -> Self {
Self::default()
}
pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
self.client_id = Some(client_id.into());
self
}
pub fn with_federated_token_file(mut self, path: impl Into<String>) -> Self {
self.token_source = FederatedTokenSource::File(path.into());
self
}
pub fn with_subject_token(mut self, subject_token: impl Into<String>) -> Self {
self.token_source =
FederatedTokenSource::Direct(LoadedSubjectToken::new(subject_token, None));
self
}
pub fn with_subject_token_and_expiration(
mut self,
subject_token: impl Into<String>,
expires_at: Timestamp,
) -> Self {
self.token_source =
FederatedTokenSource::Direct(LoadedSubjectToken::new(subject_token, Some(expires_at)));
self
}
pub fn with_authority_host(mut self, authority_host: impl Into<String>) -> Self {
self.authority_host = Some(authority_host.into());
self
}
async fn load_subject_token(
&self,
ctx: &Context,
envs: &std::collections::HashMap<String, String>,
) -> Result<Option<LoadedSubjectToken>> {
let subject_token = match &self.token_source {
FederatedTokenSource::Environment => {
let Some(path) = envs
.get("AZURE_FEDERATED_TOKEN_FILE")
.filter(|path| !path.is_empty())
else {
return Ok(None);
};
load_file_subject_token(ctx, path).await?
}
FederatedTokenSource::File(path) => load_file_subject_token(ctx, path).await?,
FederatedTokenSource::Direct(subject_token) => Some(subject_token.clone()),
};
let Some(subject_token) = subject_token else {
return Ok(None);
};
subject_token.validate()?;
Ok(Some(subject_token))
}
}
impl ProvideCredential for WorkloadIdentityCredentialProvider {
type Credential = Credential;
async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
let envs = ctx.env_vars();
let tenant_id = match self
.tenant_id
.as_ref()
.or_else(|| envs.get("AZURE_TENANT_ID"))
{
Some(id) if !id.is_empty() => id,
_ => return Ok(None),
};
let client_id = match self
.client_id
.as_ref()
.or_else(|| envs.get("AZURE_CLIENT_ID"))
{
Some(id) if !id.is_empty() => id,
_ => return Ok(None),
};
let authority_host = self
.authority_host
.as_ref()
.or_else(|| envs.get("AZURE_AUTHORITY_HOST"))
.filter(|h| !h.is_empty())
.map(|s| s.as_str())
.unwrap_or("https://login.microsoftonline.com");
let Some(subject_token) = self.load_subject_token(ctx, &envs).await? else {
return Ok(None);
};
let token_response = get_workload_identity_token(
tenant_id,
client_id,
&subject_token.token,
authority_host,
ctx,
)
.await?;
let expires_on = Timestamp::now() + Duration::from_secs(token_response.expires_in);
Ok(Some(Credential::with_bearer_token(
&token_response.access_token,
Some(expires_on),
)))
}
}
async fn get_workload_identity_token(
tenant_id: &str,
client_id: &str,
federated_token: &str,
authority_host: &str,
ctx: &Context,
) -> Result<EntraTokenResponse> {
let url = format!(
"{}/{}/oauth2/v2.0/token",
authority_host.trim_end_matches('/'),
tenant_id
);
let body = form_urlencoded::Serializer::new(String::new())
.append_pair("scope", "https://storage.azure.com/.default")
.append_pair("client_id", client_id)
.append_pair(
"client_assertion_type",
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
)
.append_pair("client_assertion", federated_token)
.append_pair("grant_type", "client_credentials")
.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("failed to build workload identity request")
.with_source(e)
})?;
let resp = ctx.http_send(req).await?;
if !resp.status().is_success() {
return Err(token_request_error(
"Microsoft Entra workload identity token request",
resp.status(),
));
}
parse_token_response(resp.body(), "workload identity token response")
}
async fn load_file_subject_token(ctx: &Context, path: &str) -> Result<Option<LoadedSubjectToken>> {
let content = match ctx.file_read(path).await {
Ok(content) => String::from_utf8(content).map_err(|err| {
reqsign_core::Error::unexpected("failed to parse federated token file as UTF-8")
.with_source(err)
})?,
Err(_) => return Ok(None),
};
let token = content.trim();
if token.is_empty() {
return Ok(None);
}
Ok(Some(LoadedSubjectToken::new(token, None)))
}
#[cfg(test)]
mod tests {
use super::*;
use reqsign_core::ErrorKind;
#[test]
fn caller_provided_subject_token_is_redacted_from_debug() {
let provider = WorkloadIdentityCredentialProvider::new()
.with_tenant_id("test-tenant")
.with_client_id("test-client")
.with_federated_token_file("/must-not-be-read")
.with_subject_token("caller.subject+token/");
assert!(!format!("{provider:?}").contains("caller.subject+token/"));
}
#[tokio::test]
async fn expired_subject_token_is_rejected() {
let provider = WorkloadIdentityCredentialProvider::new()
.with_tenant_id("test-tenant")
.with_client_id("test-client")
.with_subject_token_and_expiration(
"expired.subject.token",
Timestamp::now() - Duration::from_secs(1),
);
let err = provider
.provide_credential(&Context::new())
.await
.expect_err("expired token must fail");
assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
assert!(err.to_string().contains("subject token has expired"));
}
}