use std::fmt;
use std::sync::Arc;
use azure_core::Error;
use azure_core::credentials::TokenCredential;
use azure_core::error::ErrorKind;
use azure_core::http::{ClientMethodOptions, HttpClient, Method, Request, Url, headers};
use azure_identity::{ClientAssertion, ClientAssertionCredential};
use serde::Deserialize;
use super::StorageError;
const FEDERATION_AUDIENCE: &str = "api://AzureADTokenExchange";
const ENV_REQUEST_URL: &str = "ACTIONS_ID_TOKEN_REQUEST_URL";
const ENV_REQUEST_TOKEN: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
const ENV_CLIENT_ID: &str = "AZURE_CLIENT_ID";
const ENV_TENANT_ID: &str = "AZURE_TENANT_ID";
struct GithubOidcParams {
request_url: String,
request_token: String,
client_id: String,
tenant_id: String,
}
pub(crate) fn credential_from(
get: impl Fn(&str) -> Option<String>,
http_client: &Arc<dyn HttpClient>,
) -> Option<Result<Arc<dyn TokenCredential>, StorageError>> {
let params = params_from(get)?;
Some(build_credential(params, Arc::clone(http_client)))
}
fn params_from(get: impl Fn(&str) -> Option<String>) -> Option<GithubOidcParams> {
let non_empty = |key| get(key).filter(|value| !value.is_empty());
Some(GithubOidcParams {
request_url: non_empty(ENV_REQUEST_URL)?,
request_token: non_empty(ENV_REQUEST_TOKEN)?,
client_id: non_empty(ENV_CLIENT_ID)?,
tenant_id: non_empty(ENV_TENANT_ID)?,
})
}
fn build_credential(
params: GithubOidcParams,
http_client: Arc<dyn HttpClient>,
) -> Result<Arc<dyn TokenCredential>, StorageError> {
let assertion = GithubOidcAssertion {
request_url: params.request_url,
request_token: params.request_token,
http_client,
};
let credential: Arc<dyn TokenCredential> =
ClientAssertionCredential::new(params.tenant_id, params.client_id, assertion, None)
.map_err(|error| StorageError::Config {
message: format!("could not initialize GitHub OIDC credential: {error}"),
})?;
Ok(credential)
}
struct GithubOidcAssertion {
request_url: String,
request_token: String,
http_client: Arc<dyn HttpClient>,
}
impl fmt::Debug for GithubOidcAssertion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GithubOidcAssertion")
.field("request_url", &self.request_url)
.finish_non_exhaustive()
}
}
#[derive(Deserialize)]
struct OidcTokenResponse {
value: String,
}
#[async_trait::async_trait]
impl ClientAssertion for GithubOidcAssertion {
async fn secret(
&self,
_options: Option<ClientMethodOptions<'_>>,
) -> azure_core::Result<String> {
let mut url = Url::parse(&self.request_url).map_err(|error| {
Error::with_message(
ErrorKind::Credential,
format!(
"invalid GitHub OIDC request URL {:?}: {error}",
self.request_url
),
)
})?;
url.query_pairs_mut()
.append_pair("audience", FEDERATION_AUDIENCE);
let mut request = Request::new(url, Method::Get);
request.insert_header(
headers::AUTHORIZATION,
format!("Bearer {}", self.request_token),
);
request.insert_header(headers::ACCEPT, "application/json");
let response = self.http_client.execute_request(&request).await?;
let status = response.status();
let body = response.into_body().collect().await?;
if !status.is_success() {
return Err(Error::with_message(
ErrorKind::Credential,
format!("GitHub OIDC token request failed with HTTP status {status}"),
));
}
let parsed: OidcTokenResponse = serde_json::from_slice(&body).map_err(|error| {
Error::with_message(
ErrorKind::Credential,
format!("could not parse GitHub OIDC token response: {error}"),
)
})?;
if parsed.value.is_empty() {
return Err(Error::with_message(
ErrorKind::Credential,
"GitHub OIDC token response carried an empty token value",
));
}
Ok(parsed.value)
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use std::sync::Mutex;
use azure_core::http::headers::Headers;
use azure_core::http::{AsyncRawResponse, StatusCode};
use futures::executor::block_on;
use super::*;
#[derive(Clone, Debug, Default)]
struct SeenRequest {
url: String,
authorization: Option<String>,
}
#[derive(Debug)]
struct StubHttpClient {
status: StatusCode,
body: Vec<u8>,
fail: Option<String>,
seen: Mutex<Option<SeenRequest>>,
}
impl StubHttpClient {
fn new(status: StatusCode, body: impl Into<Vec<u8>>) -> Self {
Self {
status,
body: body.into(),
fail: None,
seen: Mutex::new(None),
}
}
fn failing(message: impl Into<String>) -> Self {
Self {
status: StatusCode::Ok,
body: Vec::new(),
fail: Some(message.into()),
seen: Mutex::new(None),
}
}
fn seen(&self) -> SeenRequest {
self.seen
.lock()
.unwrap()
.clone()
.expect("a request should have been sent")
}
}
#[async_trait::async_trait]
impl HttpClient for StubHttpClient {
async fn execute_request(&self, request: &Request) -> azure_core::Result<AsyncRawResponse> {
let authorization = request
.headers()
.get_optional_str(&headers::AUTHORIZATION)
.map(ToOwned::to_owned);
*self.seen.lock().unwrap() = Some(SeenRequest {
url: request.url().to_string(),
authorization,
});
if let Some(message) = &self.fail {
return Err(Error::with_message(ErrorKind::Io, message.clone()));
}
Ok(AsyncRawResponse::from_bytes(
self.status,
Headers::default(),
self.body.clone(),
))
}
}
fn assertion(client: Arc<dyn HttpClient>) -> GithubOidcAssertion {
GithubOidcAssertion {
request_url: "https://example.test/token?api-version=2.0".to_owned(),
request_token: "request-secret".to_owned(),
http_client: client,
}
}
#[test]
fn secret_returns_token_value_and_sends_audience_and_bearer() {
let client = Arc::new(StubHttpClient::new(
StatusCode::Ok,
br#"{"value":"the-jwt"}"#.to_vec(),
));
let client_for_assertion = Arc::clone(&client);
let assertion = assertion(client_for_assertion);
let token = block_on(assertion.secret(None)).expect("token");
assert_eq!(token, "the-jwt");
let seen = client.seen();
assert!(seen.url.contains("api-version=2.0"), "{}", seen.url);
assert!(
seen.url
.contains("audience=api%3A%2F%2FAzureADTokenExchange"),
"{}",
seen.url
);
assert_eq!(seen.authorization.as_deref(), Some("Bearer request-secret"));
}
#[test]
fn secret_maps_http_error_status_to_credential_error() {
let client = Arc::new(StubHttpClient::new(StatusCode::Forbidden, b"nope".to_vec()));
let error = block_on(assertion(client).secret(None)).expect_err("error");
assert!(matches!(error.kind(), ErrorKind::Credential), "{error:?}");
}
#[test]
fn secret_propagates_a_transport_error_unchanged() {
let client = Arc::new(StubHttpClient::failing("connection reset by peer"));
let error = block_on(assertion(client).secret(None)).expect_err("error");
assert!(matches!(error.kind(), ErrorKind::Io), "{error:?}");
}
#[test]
fn secret_maps_malformed_json_to_credential_error() {
let client = Arc::new(StubHttpClient::new(StatusCode::Ok, b"not json".to_vec()));
let error = block_on(assertion(client).secret(None)).expect_err("error");
assert!(matches!(error.kind(), ErrorKind::Credential), "{error:?}");
}
#[test]
fn secret_rejects_an_empty_token_value() {
let client = Arc::new(StubHttpClient::new(
StatusCode::Ok,
br#"{"value":""}"#.to_vec(),
));
let error = block_on(assertion(client).secret(None)).expect_err("error");
assert!(matches!(error.kind(), ErrorKind::Credential), "{error:?}");
}
#[test]
fn secret_rejects_an_invalid_request_url() {
let client = Arc::new(StubHttpClient::new(
StatusCode::Ok,
br#"{"value":"x"}"#.to_vec(),
));
let mut assertion = assertion(client);
assertion.request_url = "not a url".to_owned();
let error = block_on(assertion.secret(None)).expect_err("error");
assert!(matches!(error.kind(), ErrorKind::Credential), "{error:?}");
}
#[test]
fn debug_redacts_the_request_token() {
let client = Arc::new(StubHttpClient::new(StatusCode::Ok, b"{}".to_vec()));
let rendered = format!("{:?}", assertion(client));
assert!(!rendered.contains("request-secret"), "{rendered}");
assert!(rendered.contains("example.test"), "{rendered}");
}
fn full_env() -> Vec<(&'static str, &'static str)> {
vec![
(ENV_REQUEST_URL, "https://example.test/token"),
(ENV_REQUEST_TOKEN, "secret"),
(ENV_CLIENT_ID, "client"),
(ENV_TENANT_ID, "tenant"),
]
}
fn getter(pairs: Vec<(&'static str, &'static str)>) -> impl Fn(&str) -> Option<String> {
move |key| {
pairs
.iter()
.find(|(name, _)| *name == key)
.map(|(_, value)| (*value).to_owned())
}
}
#[test]
fn params_present_when_all_four_set() {
let params = params_from(getter(full_env())).expect("params");
assert_eq!(params.request_url, "https://example.test/token");
assert_eq!(params.request_token, "secret");
assert_eq!(params.client_id, "client");
assert_eq!(params.tenant_id, "tenant");
}
#[test]
fn params_absent_when_any_var_missing() {
for missing in [
ENV_REQUEST_URL,
ENV_REQUEST_TOKEN,
ENV_CLIENT_ID,
ENV_TENANT_ID,
] {
let pairs: Vec<_> = full_env()
.into_iter()
.filter(|(name, _)| *name != missing)
.collect();
assert!(params_from(getter(pairs)).is_none(), "missing {missing}");
}
}
#[test]
fn params_absent_when_a_var_is_empty() {
let pairs: Vec<_> = full_env()
.into_iter()
.map(|(name, value)| {
if name == ENV_CLIENT_ID {
(name, "")
} else {
(name, value)
}
})
.collect();
assert!(params_from(getter(pairs)).is_none());
}
#[test]
#[cfg_attr(
miri,
ignore = "builds a real HTTP pipeline (reqwest) that Miri cannot run"
)]
fn build_credential_succeeds_with_valid_params() {
let http_client: Arc<dyn HttpClient> =
Arc::new(StubHttpClient::new(StatusCode::Ok, b"{}".to_vec()));
let params = GithubOidcParams {
request_url: "https://example.test/token".to_owned(),
request_token: "secret".to_owned(),
client_id: "11111111-1111-1111-1111-111111111111".to_owned(),
tenant_id: "22222222-2222-2222-2222-222222222222".to_owned(),
};
let credential = build_credential(params, http_client);
assert!(credential.is_ok(), "{credential:?}");
}
#[test]
fn build_credential_rejects_an_invalid_tenant_id() {
let http_client: Arc<dyn HttpClient> =
Arc::new(StubHttpClient::new(StatusCode::Ok, b"{}".to_vec()));
let params = GithubOidcParams {
request_url: "https://example.test/token".to_owned(),
request_token: "secret".to_owned(),
client_id: "client".to_owned(),
tenant_id: "not a valid tenant".to_owned(),
};
let error = build_credential(params, http_client).expect_err("error");
assert!(matches!(error, StorageError::Config { .. }), "{error:?}");
}
#[test]
#[cfg_attr(
miri,
ignore = "builds a real HTTP pipeline (reqwest) that Miri cannot run"
)]
fn credential_from_builds_a_credential_when_all_params_present() {
let http_client: Arc<dyn HttpClient> =
Arc::new(StubHttpClient::new(StatusCode::Ok, b"{}".to_vec()));
let present = vec![
(ENV_REQUEST_URL, "https://example.test/token"),
(ENV_REQUEST_TOKEN, "secret"),
(ENV_CLIENT_ID, "11111111-1111-1111-1111-111111111111"),
(ENV_TENANT_ID, "22222222-2222-2222-2222-222222222222"),
];
let result =
credential_from(getter(present), &http_client).expect("params present yields Some");
assert!(result.is_ok(), "{result:?}");
}
#[test]
fn credential_from_absent_when_a_var_is_missing() {
let http_client: Arc<dyn HttpClient> =
Arc::new(StubHttpClient::new(StatusCode::Ok, b"{}".to_vec()));
assert!(credential_from(getter(Vec::new()), &http_client).is_none());
}
}