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}