Skip to main content

cellos_broker_oidc/
lib.rs

1//! [`SecretBroker`] that resolves GitHub Actions OIDC tokens.
2//!
3//! When a CI job runs with `permissions: id-token: write`, GitHub injects two
4//! environment variables:
5//! - `ACTIONS_ID_TOKEN_REQUEST_URL` — endpoint to request a signed OIDC JWT
6//! - `ACTIONS_ID_TOKEN_REQUEST_TOKEN` — bearer token to authenticate the request
7//!
8//! # Usage
9//!
10//! In `spec.authority.secretRefs`, list the logical secret alias that the
11//! workload will read (for example `AWS_WEB_IDENTITY`). The requested OIDC
12//! audience still comes from `spec.identity.audience`.
13//!
14//! ```json
15//! { "secretRefs": ["AWS_WEB_IDENTITY"] }
16//! ```
17//!
18//! The broker calls:
19//! ```text
20//! GET {ACTIONS_ID_TOKEN_REQUEST_URL}&audience={key}
21//! Authorization: Bearer {ACTIONS_ID_TOKEN_REQUEST_TOKEN}
22//! ```
23//!
24//! and returns the signed JWT as the secret value. The cell workload then uses
25//! this JWT to authenticate with AWS STS `AssumeRoleWithWebIdentity`, GCP
26//! Workload Identity Federation, or any other OIDC-aware identity provider.
27//!
28//! # Revocation
29//!
30//! `revoke_for_cell` is a no-op. OIDC tokens are short-lived (5 minutes for
31//! GitHub) and audience-scoped. Isolation relies on token TTL and the cell
32//! model's teardown semantics.
33//!
34//! # Timeout contract (BROKER-OIDC-TIMEOUT)
35//!
36//! The reqwest client is built with **bounded** request and connect timeouts so a
37//! hung GitHub Actions ID-token endpoint cannot stall a cell's secret-resolve
38//! phase indefinitely:
39//!
40//! - Request timeout: [`DEFAULT_REQUEST_TIMEOUT_MS`] (override via
41//!   `CELLOS_BROKER_OIDC_TIMEOUT_MS`).
42//! - Connect timeout: [`DEFAULT_CONNECT_TIMEOUT_MS`] (override via
43//!   `CELLOS_BROKER_OIDC_CONNECT_TIMEOUT_MS`).
44//!
45//! Both env vars accept a positive `u64` count of milliseconds; unparseable or
46//! zero values fall back to the default. The client is **never** constructed
47//! without explicit timeouts.
48//!
49//! # Correlation propagation (Tranche-1 seam-freeze G1)
50//!
51//! GitHub Actions injects `GITHUB_RUN_ID` and related identifiers as ambient
52//! env, but this broker scopes itself to the OIDC token request only and
53//! does not promote those identifiers into broker-level correlation today.
54//! [`SecretBroker::broker_correlation_id`] therefore returns `None` here;
55//! the supervisor falls back to the operator-supplied
56//! `spec.correlation.correlationId` (or the `externalRunId` /
57//! `externalJobId` already present in `spec.correlation`) for cross-tool
58//! correlation. A future revision may surface the OIDC `jti` claim as the
59//! broker correlation ID once consumer tools (taudit, tencrypt) have
60//! committed to a `urn:cellos:oidc:<jti>` shape.
61
62use async_trait::async_trait;
63use cellos_core::ports::SecretBroker;
64use cellos_core::{CellosError, SecretView};
65use serde::Deserialize;
66use std::fmt;
67use std::time::Duration;
68use zeroize::Zeroizing;
69
70/// Default total request timeout (ms) applied to every OIDC token request.
71///
72/// 30 seconds is long enough for a slow CI control plane, short enough that a
73/// black-holed endpoint does not block the secret-resolve phase indefinitely.
74pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000;
75
76/// Default TCP connect timeout (ms) for the underlying reqwest client.
77pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
78
79/// Env var to override [`DEFAULT_REQUEST_TIMEOUT_MS`].
80pub const ENV_REQUEST_TIMEOUT_MS: &str = "CELLOS_BROKER_OIDC_TIMEOUT_MS";
81
82/// Env var to override [`DEFAULT_CONNECT_TIMEOUT_MS`].
83pub const ENV_CONNECT_TIMEOUT_MS: &str = "CELLOS_BROKER_OIDC_CONNECT_TIMEOUT_MS";
84
85/// Resolve a timeout in milliseconds from the named env var.
86///
87/// Returns `default_ms` when the env var is unset, empty, non-numeric, or `0`.
88/// Pure function — exposed so callers (and contract tests) can verify the
89/// resolution policy without constructing a client.
90pub fn resolve_timeout_ms(env_var: &str, default_ms: u64) -> u64 {
91    match std::env::var(env_var) {
92        Ok(raw) => raw
93            .trim()
94            .parse::<u64>()
95            .ok()
96            .filter(|v| *v > 0)
97            .unwrap_or(default_ms),
98        Err(_) => default_ms,
99    }
100}
101
102/// Fetches a GitHub Actions OIDC JWT for the given audience.
103///
104/// Reads `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN`
105/// from the environment at each `resolve` call — these vars are injected fresh
106/// by the Actions runtime and may be absent outside GitHub Actions.
107///
108/// # D7 — Debug-doesn't-leak
109///
110/// `Debug` is implemented manually. Although this struct does not currently
111/// store any token material at rest (the OIDC request bearer and the resulting
112/// JWT are only held briefly inside `fetch_token`), the manual impl is the
113/// invariant that prevents a future `#[derive(Debug)]` from silently exposing
114/// secret-bearing fields if any are added.
115pub struct GithubActionsOidcBroker {
116    client: reqwest::Client,
117}
118
119impl fmt::Debug for GithubActionsOidcBroker {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.debug_struct("GithubActionsOidcBroker")
122            .field("client", &"<reqwest::Client>")
123            .finish()
124    }
125}
126
127/// JSON shape returned by the GitHub Actions ID-token endpoint.
128///
129/// `value` is the signed OIDC JWT — sensitive material. The struct deliberately
130/// does **not** derive `Debug`; a manual redacting impl prevents any accidental
131/// `tracing::debug!(?resp, ...)` from logging the raw token.
132#[derive(Deserialize)]
133struct OidcTokenResponse {
134    value: String,
135}
136
137impl fmt::Debug for OidcTokenResponse {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.debug_struct("OidcTokenResponse")
140            .field("value", &"<redacted oidc-jwt>")
141            .finish()
142    }
143}
144
145/// Build a reqwest client that honours `CELLOS_CA_BUNDLE` (path to a PEM CA bundle).
146///
147/// Always installs **bounded** request and connect timeouts (see module docs).
148fn http_client_builder() -> Result<reqwest::ClientBuilder, String> {
149    let request_timeout = Duration::from_millis(resolve_timeout_ms(
150        ENV_REQUEST_TIMEOUT_MS,
151        DEFAULT_REQUEST_TIMEOUT_MS,
152    ));
153    let connect_timeout = Duration::from_millis(resolve_timeout_ms(
154        ENV_CONNECT_TIMEOUT_MS,
155        DEFAULT_CONNECT_TIMEOUT_MS,
156    ));
157    let mut builder = reqwest::Client::builder()
158        .timeout(request_timeout)
159        .connect_timeout(connect_timeout);
160    if let Ok(path) = std::env::var("CELLOS_CA_BUNDLE") {
161        let pem =
162            std::fs::read(&path).map_err(|e| format!("CELLOS_CA_BUNDLE: read {path}: {e}"))?;
163        let mut added = 0usize;
164        for block in pem_cert_blocks(&pem) {
165            let cert = reqwest::Certificate::from_pem(&block)
166                .map_err(|e| format!("CELLOS_CA_BUNDLE: parse cert in {path}: {e}"))?;
167            builder = builder.add_root_certificate(cert);
168            added += 1;
169        }
170        if added == 0 {
171            return Err(format!("CELLOS_CA_BUNDLE: no certificates found in {path}"));
172        }
173        tracing::debug!(path = %path, count = added, "CELLOS_CA_BUNDLE: loaded CA certificates");
174    }
175    Ok(builder)
176}
177
178fn pem_cert_blocks(pem: &[u8]) -> Vec<Vec<u8>> {
179    let text = String::from_utf8_lossy(pem);
180    let mut blocks = Vec::new();
181    let mut current = String::new();
182    let mut in_block = false;
183    for line in text.lines() {
184        if line.starts_with("-----BEGIN ") {
185            in_block = true;
186            current.clear();
187        }
188        if in_block {
189            current.push_str(line);
190            current.push('\n');
191            if line.starts_with("-----END ") {
192                blocks.push(current.as_bytes().to_vec());
193                in_block = false;
194            }
195        }
196    }
197    blocks
198}
199
200impl GithubActionsOidcBroker {
201    pub fn new() -> Result<Self, CellosError> {
202        let client = http_client_builder()
203            .map_err(CellosError::SecretBroker)?
204            .build()
205            .map_err(|e| CellosError::SecretBroker(format!("oidc http client init: {e}")))?;
206        Ok(Self { client })
207    }
208
209    async fn fetch_token(&self, audience: &str) -> Result<String, CellosError> {
210        let base_url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL").map_err(|_| {
211            CellosError::SecretBroker(
212                "ACTIONS_ID_TOKEN_REQUEST_URL not set — is this a GitHub Actions environment \
213                 with `permissions: id-token: write`?"
214                    .into(),
215            )
216        })?;
217        // The Actions ID-token request bearer is sensitive: anyone holding it can
218        // mint OIDC JWTs for this job. Wrap in `Zeroizing` so the bytes are wiped
219        // from memory once `request_token` goes out of scope at the end of this fn.
220        let request_token: Zeroizing<String> = Zeroizing::new(
221            std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN").map_err(|_| {
222                CellosError::SecretBroker("ACTIONS_ID_TOKEN_REQUEST_TOKEN not set".into())
223            })?,
224        );
225
226        // GitHub's endpoint appends `audience` as a query param to the base URL.
227        // The base URL may already have query params so we append with `&`.
228        let url = format!("{base_url}&audience={}", urlencoded(audience));
229
230        tracing::debug!(audience = %audience, "fetching GitHub Actions OIDC token");
231
232        let resp = self
233            .client
234            .get(&url)
235            .bearer_auth(request_token.as_str())
236            .send()
237            .await
238            .map_err(|e| CellosError::SecretBroker(format!("oidc token request: {e}")))?;
239
240        if !resp.status().is_success() {
241            let status = resp.status();
242            let body = resp.text().await.unwrap_or_default();
243            return Err(CellosError::SecretBroker(format!(
244                "oidc token request returned {status}: {body}"
245            )));
246        }
247
248        let token_resp: OidcTokenResponse = resp
249            .json()
250            .await
251            .map_err(|e| CellosError::SecretBroker(format!("oidc token parse: {e}")))?;
252
253        tracing::info!(audience = %audience, "GitHub Actions OIDC token fetched");
254        Ok(token_resp.value)
255    }
256}
257
258impl Default for GithubActionsOidcBroker {
259    fn default() -> Self {
260        Self::new().expect("GithubActionsOidcBroker::new() should not fail in default context")
261    }
262}
263
264#[async_trait]
265impl SecretBroker for GithubActionsOidcBroker {
266    /// Resolves a GitHub Actions OIDC token for the given audience key.
267    ///
268    /// The `cell_id` and `ttl_seconds` parameters are recorded in tracing spans
269    /// for attribution but are not sent to GitHub's endpoint.
270    async fn resolve(
271        &self,
272        key: &str,
273        cell_id: &str,
274        _ttl_seconds: u64,
275    ) -> Result<SecretView, CellosError> {
276        tracing::debug!(key = %key, cell_id = %cell_id, "resolving OIDC token");
277        let jwt = self.fetch_token(key).await?;
278        Ok(SecretView {
279            key: key.to_string(),
280            value: zeroize::Zeroizing::new(jwt),
281        })
282    }
283
284    /// No-op — OIDC tokens are TTL-bound (5 min for GitHub Actions) and
285    /// audience-scoped. Runtime revocation is not possible from the runner side.
286    async fn revoke_for_cell(&self, _cell_id: &str) -> Result<(), CellosError> {
287        Ok(())
288    }
289}
290
291/// Percent-encode a string for use as a URL query parameter value.
292/// Minimal implementation — encodes only the chars that break URL parsing.
293fn urlencoded(s: &str) -> String {
294    s.chars()
295        .flat_map(|c| match c {
296            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
297                vec![c]
298            }
299            c => format!("%{:02X}", c as u32).chars().collect(),
300        })
301        .collect()
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn urlencoded_passthrough_safe_chars() {
310        assert_eq!(urlencoded("sts.amazonaws.com"), "sts.amazonaws.com");
311        assert_eq!(urlencoded("my-audience"), "my-audience");
312        assert_eq!(urlencoded("abc123"), "abc123");
313    }
314
315    #[test]
316    fn urlencoded_encodes_special_chars() {
317        assert_eq!(urlencoded("a b"), "a%20b");
318        assert_eq!(urlencoded("a/b"), "a%2Fb");
319        assert_eq!(urlencoded("a:b"), "a%3Ab");
320    }
321
322    #[tokio::test]
323    async fn resolve_fails_outside_github_actions() {
324        // In a non-Actions env, ACTIONS_ID_TOKEN_REQUEST_URL is not set.
325        std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_URL");
326        std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
327        let broker = GithubActionsOidcBroker::new().unwrap();
328        let err = broker
329            .resolve("sts.amazonaws.com", "cell-1", 300)
330            .await
331            .unwrap_err();
332        assert!(
333            err.to_string().contains("ACTIONS_ID_TOKEN_REQUEST_URL"),
334            "expected env var name in error, got: {err}"
335        );
336    }
337
338    /// Red-team wave-2 T4: `OidcTokenResponse` `Debug` impl must redact the
339    /// signed-JWT `value` so a stray `tracing::error!(?resp, ...)` on parse
340    /// failure cannot surface token bytes to log pipelines.
341    #[test]
342    fn oidc_token_response_debug_redacts_value() {
343        let resp = OidcTokenResponse {
344            value: "OIDC-WAVE2-T4-INLINE-SENTINEL".to_string(),
345        };
346        let dbg = format!("{resp:?}");
347        assert!(!dbg.contains("OIDC-WAVE2-T4-INLINE-SENTINEL"));
348        assert!(dbg.contains("redacted"));
349    }
350
351    /// Red-team wave-2 T4: broker-struct Debug must not widen to leak bearer.
352    #[test]
353    fn github_oidc_broker_debug_does_not_widen() {
354        let broker = GithubActionsOidcBroker::new().unwrap();
355        let dbg = format!("{broker:?}");
356        assert!(!dbg.contains("Bearer "));
357    }
358}