cellos-broker-oidc 0.5.1

OIDC SecretBroker for CellOS — fetches workload identity tokens via OIDC token exchange (GitHub Actions, GitLab CI, Azure DevOps).
Documentation
//! [`SecretBroker`] that resolves GitHub Actions OIDC tokens.
//!
//! When a CI job runs with `permissions: id-token: write`, GitHub injects two
//! environment variables:
//! - `ACTIONS_ID_TOKEN_REQUEST_URL` — endpoint to request a signed OIDC JWT
//! - `ACTIONS_ID_TOKEN_REQUEST_TOKEN` — bearer token to authenticate the request
//!
//! # Usage
//!
//! In `spec.authority.secretRefs`, list the logical secret alias that the
//! workload will read (for example `AWS_WEB_IDENTITY`). The requested OIDC
//! audience still comes from `spec.identity.audience`.
//!
//! ```json
//! { "secretRefs": ["AWS_WEB_IDENTITY"] }
//! ```
//!
//! The broker calls:
//! ```text
//! GET {ACTIONS_ID_TOKEN_REQUEST_URL}&audience={key}
//! Authorization: Bearer {ACTIONS_ID_TOKEN_REQUEST_TOKEN}
//! ```
//!
//! and returns the signed JWT as the secret value. The cell workload then uses
//! this JWT to authenticate with AWS STS `AssumeRoleWithWebIdentity`, GCP
//! Workload Identity Federation, or any other OIDC-aware identity provider.
//!
//! # Revocation
//!
//! `revoke_for_cell` is a no-op. OIDC tokens are short-lived (5 minutes for
//! GitHub) and audience-scoped. Isolation relies on token TTL and the cell
//! model's teardown semantics.
//!
//! # Timeout contract (BROKER-OIDC-TIMEOUT)
//!
//! The reqwest client is built with **bounded** request and connect timeouts so a
//! hung GitHub Actions ID-token endpoint cannot stall a cell's secret-resolve
//! phase indefinitely:
//!
//! - Request timeout: [`DEFAULT_REQUEST_TIMEOUT_MS`] (override via
//!   `CELLOS_BROKER_OIDC_TIMEOUT_MS`).
//! - Connect timeout: [`DEFAULT_CONNECT_TIMEOUT_MS`] (override via
//!   `CELLOS_BROKER_OIDC_CONNECT_TIMEOUT_MS`).
//!
//! Both env vars accept a positive `u64` count of milliseconds; unparseable or
//! zero values fall back to the default. The client is **never** constructed
//! without explicit timeouts.
//!
//! # Correlation propagation (Tranche-1 seam-freeze G1)
//!
//! GitHub Actions injects `GITHUB_RUN_ID` and related identifiers as ambient
//! env, but this broker scopes itself to the OIDC token request only and
//! does not promote those identifiers into broker-level correlation today.
//! [`SecretBroker::broker_correlation_id`] therefore returns `None` here;
//! the supervisor falls back to the operator-supplied
//! `spec.correlation.correlationId` (or the `externalRunId` /
//! `externalJobId` already present in `spec.correlation`) for cross-tool
//! correlation. A future revision may surface the OIDC `jti` claim as the
//! broker correlation ID once consumer tools (taudit, tencrypt) have
//! committed to a `urn:cellos:oidc:<jti>` shape.

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;

/// Default total request timeout (ms) applied to every OIDC token request.
///
/// 30 seconds is long enough for a slow CI control plane, short enough that a
/// black-holed endpoint does not block the secret-resolve phase indefinitely.
pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000;

/// Default TCP connect timeout (ms) for the underlying reqwest client.
pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;

/// Env var to override [`DEFAULT_REQUEST_TIMEOUT_MS`].
pub const ENV_REQUEST_TIMEOUT_MS: &str = "CELLOS_BROKER_OIDC_TIMEOUT_MS";

/// Env var to override [`DEFAULT_CONNECT_TIMEOUT_MS`].
pub const ENV_CONNECT_TIMEOUT_MS: &str = "CELLOS_BROKER_OIDC_CONNECT_TIMEOUT_MS";

/// Resolve a timeout in milliseconds from the named env var.
///
/// Returns `default_ms` when the env var is unset, empty, non-numeric, or `0`.
/// Pure function — exposed so callers (and contract tests) can verify the
/// resolution policy without constructing a client.
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,
    }
}

/// Fetches a GitHub Actions OIDC JWT for the given audience.
///
/// Reads `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN`
/// from the environment at each `resolve` call — these vars are injected fresh
/// by the Actions runtime and may be absent outside GitHub Actions.
///
/// # D7 — Debug-doesn't-leak
///
/// `Debug` is implemented manually. Although this struct does not currently
/// store any token material at rest (the OIDC request bearer and the resulting
/// JWT are only held briefly inside `fetch_token`), the manual impl is the
/// invariant that prevents a future `#[derive(Debug)]` from silently exposing
/// secret-bearing fields if any are added.
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()
    }
}

/// JSON shape returned by the GitHub Actions ID-token endpoint.
///
/// `value` is the signed OIDC JWT — sensitive material. The struct deliberately
/// does **not** derive `Debug`; a manual redacting impl prevents any accidental
/// `tracing::debug!(?resp, ...)` from logging the raw token.
#[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()
    }
}

/// Build a reqwest client that honours `CELLOS_CA_BUNDLE` (path to a PEM CA bundle).
///
/// Always installs **bounded** request and connect timeouts (see module docs).
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(),
            )
        })?;
        // The Actions ID-token request bearer is sensitive: anyone holding it can
        // mint OIDC JWTs for this job. Wrap in `Zeroizing` so the bytes are wiped
        // from memory once `request_token` goes out of scope at the end of this fn.
        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())
            })?,
        );

        // GitHub's endpoint appends `audience` as a query param to the base URL.
        // The base URL may already have query params so we append with `&`.
        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 {
    /// Resolves a GitHub Actions OIDC token for the given audience key.
    ///
    /// The `cell_id` and `ttl_seconds` parameters are recorded in tracing spans
    /// for attribution but are not sent to GitHub's endpoint.
    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),
        })
    }

    /// No-op — OIDC tokens are TTL-bound (5 min for GitHub Actions) and
    /// audience-scoped. Runtime revocation is not possible from the runner side.
    async fn revoke_for_cell(&self, _cell_id: &str) -> Result<(), CellosError> {
        Ok(())
    }
}

/// Percent-encode a string for use as a URL query parameter value.
/// Minimal implementation — encodes only the chars that break URL parsing.
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() {
        // In a non-Actions env, ACTIONS_ID_TOKEN_REQUEST_URL is not set.
        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}"
        );
    }

    /// Red-team wave-2 T4: `OidcTokenResponse` `Debug` impl must redact the
    /// signed-JWT `value` so a stray `tracing::error!(?resp, ...)` on parse
    /// failure cannot surface token bytes to log pipelines.
    #[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"));
    }

    /// Red-team wave-2 T4: broker-struct Debug must not widen to leak bearer.
    #[test]
    fn github_oidc_broker_debug_does_not_widen() {
        let broker = GithubActionsOidcBroker::new().unwrap();
        let dbg = format!("{broker:?}");
        assert!(!dbg.contains("Bearer "));
    }
}