Skip to main content

auths_cli/commands/artifact/
oidc.rs

1//! The sign-time half of the keyless CI exchange: validate the runner's
2//! OIDC token and turn it into a signature-covered [`OidcBinding`].
3//!
4//! The runner never holds an org key. It presents the token its CI platform
5//! minted for the job; we validate it — signature against the issuer's JWKS,
6//! issuer, audience, expiry — and embed the verified, platform-normalized
7//! claims in the attestation's signed envelope. The org's side of the
8//! exchange happens at verify time: `artifact verify --oidc-policy` joins
9//! these claims against the OIDC-subject policy the org pinned.
10
11use std::path::Path;
12use std::sync::Arc;
13
14use anyhow::{Context, Result, bail};
15use chrono::{DateTime, Utc};
16
17use auths_infra_http::{HttpJwksClient, HttpJwtValidator, PinnedJwksClient};
18use auths_sdk::domains::signing::ci_env::CiPlatform;
19use auths_sdk::workflows::ci::machine_identity::{
20    OidcMachineIdentityConfig, create_machine_identity_from_oidc_token,
21};
22use auths_verifier::core::OidcBinding;
23
24/// The default OIDC issuer when none is given: GitHub Actions, the platform
25/// the zero-secret CI story ships on first.
26pub const DEFAULT_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com";
27
28/// Map the detected CI platform onto the OIDC claim-normalization platform.
29///
30/// Fail-closed: a platform without a known OIDC claim shape cannot produce a
31/// binding the policy join can trust, so it is an error — never a guess.
32fn oidc_platform(platform: &CiPlatform) -> Result<&'static str> {
33    match platform {
34        CiPlatform::GithubActions => Ok("github"),
35        CiPlatform::GitlabCi => Ok("gitlab"),
36        CiPlatform::CircleCi => Ok("circleci"),
37        CiPlatform::Generic | CiPlatform::Local => bail!(
38            "--oidc-token needs a CI platform with a known OIDC claim shape \
39             (GitHub Actions, GitLab CI, or CircleCI) — detected {:?}",
40            platform
41        ),
42    }
43}
44
45/// Validate an OIDC token and return the verified binding to embed.
46///
47/// Args:
48/// * `token_path`: File holding the raw JWT (tokens are bearer secrets —
49///   they travel by file, never argv).
50/// * `issuer`: Expected token issuer (exact match).
51/// * `audience`: Expected token audience (exact match).
52/// * `jwks_path`: Optional pinned JWKS file. When absent, the issuer's
53///   published JWKS is fetched over HTTPS.
54/// * `platform`: The CI platform whose claim shape normalizes the token.
55/// * `now`: Current time for expiry validation.
56pub fn resolve_oidc_binding(
57    token_path: &Path,
58    issuer: &str,
59    audience: &str,
60    jwks_path: Option<&Path>,
61    platform: &CiPlatform,
62    now: DateTime<Utc>,
63) -> Result<OidcBinding> {
64    let oidc_platform = oidc_platform(platform)?;
65
66    let token = std::fs::read_to_string(token_path)
67        .with_context(|| format!("Failed to read OIDC token from {token_path:?}"))?;
68    let token = token.trim();
69    if token.is_empty() {
70        bail!("OIDC token file {token_path:?} is empty");
71    }
72
73    let validator = match jwks_path {
74        Some(path) => {
75            let jwks_raw = std::fs::read_to_string(path)
76                .with_context(|| format!("Failed to read pinned JWKS from {path:?}"))?;
77            let jwks: serde_json::Value = serde_json::from_str(&jwks_raw)
78                .with_context(|| format!("Pinned JWKS {path:?} is not valid JSON"))?;
79            HttpJwtValidator::new(Arc::new(PinnedJwksClient::new(jwks)))
80        }
81        None => HttpJwtValidator::new(Arc::new(HttpJwksClient::with_default_ttl())),
82    };
83
84    let config = OidcMachineIdentityConfig {
85        issuer: issuer.to_string(),
86        audience: audience.to_string(),
87        platform: oidc_platform.to_string(),
88    };
89
90    let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
91    let identity = rt
92        .block_on(create_machine_identity_from_oidc_token(
93            token,
94            config,
95            Arc::new(validator),
96            now,
97        ))
98        .map_err(|e| anyhow::anyhow!("OIDC token validation failed: {e}"))?;
99
100    Ok(OidcBinding::from(&identity))
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn local_platform_cannot_present_a_token() {
109        let err = oidc_platform(&CiPlatform::Local).expect_err("local must be rejected");
110        assert!(err.to_string().contains("known OIDC claim shape"));
111    }
112
113    #[test]
114    fn github_maps_to_github_claim_shape() {
115        assert_eq!(oidc_platform(&CiPlatform::GithubActions).unwrap(), "github");
116    }
117}