use async_trait::async_trait;
use cellos_core::ports::SecretBroker;
use cellos_core::{CellosError, SecretView};
use serde::Deserialize;
use std::fmt;
use std::time::Duration;
use zeroize::Zeroizing;
pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000;
pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
pub const ENV_REQUEST_TIMEOUT_MS: &str = "CELLOS_BROKER_OIDC_TIMEOUT_MS";
pub const ENV_CONNECT_TIMEOUT_MS: &str = "CELLOS_BROKER_OIDC_CONNECT_TIMEOUT_MS";
pub fn resolve_timeout_ms(env_var: &str, default_ms: u64) -> u64 {
match std::env::var(env_var) {
Ok(raw) => raw
.trim()
.parse::<u64>()
.ok()
.filter(|v| *v > 0)
.unwrap_or(default_ms),
Err(_) => default_ms,
}
}
pub struct GithubActionsOidcBroker {
client: reqwest::Client,
}
impl fmt::Debug for GithubActionsOidcBroker {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GithubActionsOidcBroker")
.field("client", &"<reqwest::Client>")
.finish()
}
}
#[derive(Deserialize)]
struct OidcTokenResponse {
value: String,
}
impl fmt::Debug for OidcTokenResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OidcTokenResponse")
.field("value", &"<redacted oidc-jwt>")
.finish()
}
}
fn http_client_builder() -> Result<reqwest::ClientBuilder, String> {
let request_timeout = Duration::from_millis(resolve_timeout_ms(
ENV_REQUEST_TIMEOUT_MS,
DEFAULT_REQUEST_TIMEOUT_MS,
));
let connect_timeout = Duration::from_millis(resolve_timeout_ms(
ENV_CONNECT_TIMEOUT_MS,
DEFAULT_CONNECT_TIMEOUT_MS,
));
let mut builder = reqwest::Client::builder()
.timeout(request_timeout)
.connect_timeout(connect_timeout);
if let Ok(path) = std::env::var("CELLOS_CA_BUNDLE") {
let pem =
std::fs::read(&path).map_err(|e| format!("CELLOS_CA_BUNDLE: read {path}: {e}"))?;
let mut added = 0usize;
for block in pem_cert_blocks(&pem) {
let cert = reqwest::Certificate::from_pem(&block)
.map_err(|e| format!("CELLOS_CA_BUNDLE: parse cert in {path}: {e}"))?;
builder = builder.add_root_certificate(cert);
added += 1;
}
if added == 0 {
return Err(format!("CELLOS_CA_BUNDLE: no certificates found in {path}"));
}
tracing::debug!(path = %path, count = added, "CELLOS_CA_BUNDLE: loaded CA certificates");
}
Ok(builder)
}
fn pem_cert_blocks(pem: &[u8]) -> Vec<Vec<u8>> {
let text = String::from_utf8_lossy(pem);
let mut blocks = Vec::new();
let mut current = String::new();
let mut in_block = false;
for line in text.lines() {
if line.starts_with("-----BEGIN ") {
in_block = true;
current.clear();
}
if in_block {
current.push_str(line);
current.push('\n');
if line.starts_with("-----END ") {
blocks.push(current.as_bytes().to_vec());
in_block = false;
}
}
}
blocks
}
impl GithubActionsOidcBroker {
pub fn new() -> Result<Self, CellosError> {
let client = http_client_builder()
.map_err(CellosError::SecretBroker)?
.build()
.map_err(|e| CellosError::SecretBroker(format!("oidc http client init: {e}")))?;
Ok(Self { client })
}
async fn fetch_token(&self, audience: &str) -> Result<String, CellosError> {
let base_url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL").map_err(|_| {
CellosError::SecretBroker(
"ACTIONS_ID_TOKEN_REQUEST_URL not set — is this a GitHub Actions environment \
with `permissions: id-token: write`?"
.into(),
)
})?;
let request_token: Zeroizing<String> = Zeroizing::new(
std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN").map_err(|_| {
CellosError::SecretBroker("ACTIONS_ID_TOKEN_REQUEST_TOKEN not set".into())
})?,
);
let url = format!("{base_url}&audience={}", urlencoded(audience));
tracing::debug!(audience = %audience, "fetching GitHub Actions OIDC token");
let resp = self
.client
.get(&url)
.bearer_auth(request_token.as_str())
.send()
.await
.map_err(|e| CellosError::SecretBroker(format!("oidc token request: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(CellosError::SecretBroker(format!(
"oidc token request returned {status}: {body}"
)));
}
let token_resp: OidcTokenResponse = resp
.json()
.await
.map_err(|e| CellosError::SecretBroker(format!("oidc token parse: {e}")))?;
tracing::info!(audience = %audience, "GitHub Actions OIDC token fetched");
Ok(token_resp.value)
}
}
impl Default for GithubActionsOidcBroker {
fn default() -> Self {
Self::new().expect("GithubActionsOidcBroker::new() should not fail in default context")
}
}
#[async_trait]
impl SecretBroker for GithubActionsOidcBroker {
async fn resolve(
&self,
key: &str,
cell_id: &str,
_ttl_seconds: u64,
) -> Result<SecretView, CellosError> {
tracing::debug!(key = %key, cell_id = %cell_id, "resolving OIDC token");
let jwt = self.fetch_token(key).await?;
Ok(SecretView {
key: key.to_string(),
value: zeroize::Zeroizing::new(jwt),
})
}
async fn revoke_for_cell(&self, _cell_id: &str) -> Result<(), CellosError> {
Ok(())
}
}
fn urlencoded(s: &str) -> String {
s.chars()
.flat_map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
vec![c]
}
c => format!("%{:02X}", c as u32).chars().collect(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urlencoded_passthrough_safe_chars() {
assert_eq!(urlencoded("sts.amazonaws.com"), "sts.amazonaws.com");
assert_eq!(urlencoded("my-audience"), "my-audience");
assert_eq!(urlencoded("abc123"), "abc123");
}
#[test]
fn urlencoded_encodes_special_chars() {
assert_eq!(urlencoded("a b"), "a%20b");
assert_eq!(urlencoded("a/b"), "a%2Fb");
assert_eq!(urlencoded("a:b"), "a%3Ab");
}
#[tokio::test]
async fn resolve_fails_outside_github_actions() {
std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_URL");
std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
let broker = GithubActionsOidcBroker::new().unwrap();
let err = broker
.resolve("sts.amazonaws.com", "cell-1", 300)
.await
.unwrap_err();
assert!(
err.to_string().contains("ACTIONS_ID_TOKEN_REQUEST_URL"),
"expected env var name in error, got: {err}"
);
}
#[test]
fn oidc_token_response_debug_redacts_value() {
let resp = OidcTokenResponse {
value: "OIDC-WAVE2-T4-INLINE-SENTINEL".to_string(),
};
let dbg = format!("{resp:?}");
assert!(!dbg.contains("OIDC-WAVE2-T4-INLINE-SENTINEL"));
assert!(dbg.contains("redacted"));
}
#[test]
fn github_oidc_broker_debug_does_not_widen() {
let broker = GithubActionsOidcBroker::new().unwrap();
let dbg = format!("{broker:?}");
assert!(!dbg.contains("Bearer "));
}
}