OXYGENHUB

Coverage / crates/hub/src/oidc.rs 97.0%

223 of 230 lines run, 37 of 49 functions · run #471 on main · run missed

1
//! The hub as an OpenID Connect issuer for jobs (hub spec §13.5,
2
//! oxygenhub#103): a job that declares `identity "AUDIENCE" { mount-path … }`
3
//! gets a signed JWT naming the repository, bookmark, run and job, so a
4
//! cloud or a vault that trusts this hub can let the job in without a
5
//! long-lived secret. The key is RSA 2048 in `keys/oidc.pem`, made at
6
//! first use; `/.well-known/openid-configuration` and
7
//! `/.well-known/jwks.json` publish what verifiers need.
8
9
use rsa::pkcs1v15::{SigningKey, VerifyingKey};
10
use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey};
11
use rsa::signature::{SignatureEncoding, Signer, Verifier};
12
use rsa::traits::PublicKeyParts;
13
use rsa::RsaPrivateKey;
14
use sha2::Sha256;
15
use std::path::Path;
16
17
use crate::api::ApiError;
18
use crate::Shared;
19
use axum::extract::State;
20
use axum::http::StatusCode;
21
use axum::response::{IntoResponse, Response};
22
use axum::Json;
23
24
/// The longest a job token lives, whatever the job's limit.
25
pub const MAX_TTL_S: u64 = 6 * 3600;
26
27
pub struct Issuer {
28
    signing: SigningKey<Sha256>,
29
    verifying: VerifyingKey<Sha256>,
30
    /// The key id: the first 16 hex of BLAKE3 over the public key's
31
    /// modulus, as the JWKS names it.
32
    pub kid: String,
33
    n: String,
34
    e: String,
35
}
36
37
/// What a token says about its job.
38
pub struct JobClaims<'a> {
39
    pub issuer: &'a str,
40
    pub audience: &'a str,
41
    pub owner: &'a str,
42
    pub name: &'a str,
43
    pub visibility: &'a str,
44
    pub bookmark: &'a str,
45
    pub run_number: i64,
46
    pub job: &'a str,
47
    pub state: &'a str,
48
    pub runner: &'a str,
49
    pub actor: &'a str,
50
    /// The environment the job deploys to, only once it was approved
51
    /// (oxygenflow#59).
52
    pub environment: Option<&'a str>,
53
    /// Who approved the job, and the sealed approval's hash (#235).
54
    pub approved_by: Option<&'a str>,
55
    pub approval: Option<&'a str>,
56
    pub now_ms: i64,
57
    pub ttl_s: u64,
58
}
59
60
impl Issuer {
61
    /// The key from `<keys>/oidc.pem`, or a new 2048-bit one written
62
    /// there (mode 0600).
6312×
    pub fn load_or_create(keys: &Path) -> anyhow::Result<Issuer> {
6412×
        std::fs::create_dir_all(keys)?;
6512×
        let path = keys.join("oidc.pem");
6612×
        let key = match std::fs::read_to_string(&path) {
671×
            Ok(pem) => RsaPrivateKey::from_pkcs8_pem(&pem).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?,
68
            Err(_) => {
6911×
                let key = RsaPrivateKey::new(&mut rand::rngs::OsRng, 2048)?;
7011×
                let pem = key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)?;
7111×
                std::fs::write(&path, pem.as_bytes())?;
72
                #[cfg(unix)]
73
                {
74
                    use std::os::unix::fs::PermissionsExt;
7511×
                    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
76
                }
7711×
                key
78
            }
79
        };
8012×
        Ok(Issuer::from_key(key))
8112×
    }
82
8312×
    fn from_key(key: RsaPrivateKey) -> Issuer {
8412×
        let public = key.to_public_key();
8512×
        let n_bytes = public.n().to_bytes_be();
8612×
        let kid = blake3::hash(&n_bytes).to_hex()[..16].to_string();
8712×
        Issuer {
8812×
            n: b64url(&n_bytes),
8912×
            e: b64url(&public.e().to_bytes_be()),
9012×
            verifying: VerifyingKey::new(public),
9112×
            signing: SigningKey::new(key),
9212×
            kid,
9312×
        }
9412×
    }
95
96
    /// `/.well-known/openid-configuration`.
971×
    pub fn discovery(&self, issuer: &str) -> serde_json::Value {
981×
        serde_json::json!({
991×
            "issuer": issuer,
1001×
            "jwks_uri": format!("{issuer}/.well-known/jwks.json"),
1011×
            "response_types_supported": ["id_token"],
1021×
            "subject_types_supported": ["public"],
1031×
            "id_token_signing_alg_values_supported": ["RS256"],
1041×
            "claims_supported": ["iss", "sub", "aud", "exp", "iat", "nbf", "jti", "repository", "repository_owner",
105
                "repository_visibility", "bookmark", "run_number", "job", "state", "runner", "actor", "environment", "approved_by", "approval"],
1061×
            "scopes_supported": ["openid"],
107
        })
1081×
    }
109
110
    /// `/.well-known/jwks.json`.
1112×
    pub fn jwks(&self) -> serde_json::Value {
1122×
        serde_json::json!({ "keys": [{ "kty": "RSA", "use": "sig", "alg": "RS256", "kid": self.kid, "n": self.n, "e": self.e }] })
1132×
    }
114
115
    /// A signed token for a job: `sub` is `repo:OWNER/NAME:bookmark:B`,
116
    /// so a trust policy can match a repository and a bookmark.
1175×
    pub fn mint(&self, c: &JobClaims<'_>) -> String {
1185×
        let iat = c.now_ms / 1000;
1195×
        let exp = iat + c.ttl_s.clamp(60, MAX_TTL_S) as i64;
1205×
        let header = serde_json::json!({ "alg": "RS256", "typ": "JWT", "kid": self.kid });
1215×
        let mut payload = serde_json::json!({
1225×
            "iss": c.issuer,
1235×
            "sub": format!("repo:{}/{}:bookmark:{}", c.owner, c.name, c.bookmark),
1245×
            "aud": c.audience,
1255×
            "iat": iat,
1265×
            "nbf": iat - 30,
1275×
            "exp": exp,
1285×
            "jti": crate::auth::random_hex(16),
1295×
            "repository": format!("{}/{}", c.owner, c.name),
1305×
            "repository_owner": c.owner,
1315×
            "repository_visibility": c.visibility,
1325×
            "bookmark": c.bookmark,
1335×
            "run_number": c.run_number,
1345×
            "job": c.job,
1355×
            "state": c.state,
1365×
            "runner": c.runner,
1375×
            "actor": c.actor,
138
        });
1395×
        if let Some(e) = c.environment {
1403×
            payload["environment"] = serde_json::Value::from(e);
1413×
        }
1425×
        if let Some(a) = c.approved_by {
1433×
            payload["approved_by"] = serde_json::Value::from(a);
1443×
        }
1455×
        if let Some(a) = c.approval {
1462×
            payload["approval"] = serde_json::Value::from(a);
1473×
        }
1485×
        let signing_input = format!("{}.{}", b64url(header.to_string().as_bytes()), b64url(payload.to_string().as_bytes()));
1495×
        let sig = self.signing.sign(signing_input.as_bytes());
1505×
        format!("{signing_input}.{}", b64url(&sig.to_bytes()))
1515×
    }
152
153
    /// A signed token for the hub itself acting as `subject` (a backup's
154
    /// `backup:OWNER`, oxygenhub#169), for `audience`, living `ttl_s`.
15515×
    pub fn mint_for(&self, issuer: &str, subject: &str, audience: &str, now_ms: i64, ttl_s: u64) -> String {
15615×
        let iat = now_ms / 1000;
15715×
        let header = serde_json::json!({ "alg": "RS256", "typ": "JWT", "kid": self.kid });
15815×
        let payload = serde_json::json!({
15915×
            "iss": issuer,
16015×
            "sub": subject,
16115×
            "aud": audience,
16215×
            "iat": iat,
16315×
            "nbf": iat - 30,
16415×
            "exp": iat + ttl_s.clamp(60, MAX_TTL_S) as i64,
16515×
            "jti": crate::auth::random_hex(16),
166
        });
16715×
        let signing_input = format!("{}.{}", b64url(header.to_string().as_bytes()), b64url(payload.to_string().as_bytes()));
16815×
        let sig = self.signing.sign(signing_input.as_bytes());
16915×
        format!("{signing_input}.{}", b64url(&sig.to_bytes()))
17015×
    }
171
172
    /// The claims of a token this hub signed, when its signature holds,
173
    /// it is for `audience` and it has not expired at `now_ms`.
17415×
    pub fn verify(&self, token: &str, audience: &str, now_ms: i64) -> Option<serde_json::Value> {
17515×
        let mut parts = token.split('.');
17615×
        let (h, p, s) = (parts.next()?, parts.next()?, parts.next()?);
17714×
        if parts.next().is_some() {
1780×
            return None;
17914×
        }
18014×
        let header: serde_json::Value = serde_json::from_slice(&b64url_decode(h)?).ok()?;
18114×
        if header["alg"] != "RS256" || header["kid"] != self.kid.as_str() {
1821×
            return None;
18313×
        }
18413×
        let sig = rsa::pkcs1v15::Signature::try_from(b64url_decode(s)?.as_slice()).ok()?;
18513×
        self.verifying.verify(format!("{h}.{p}").as_bytes(), &sig).ok()?;
18612×
        let claims: serde_json::Value = serde_json::from_slice(&b64url_decode(p)?).ok()?;
18712×
        let now = now_ms / 1000;
18812×
        (claims["aud"] == audience && claims["exp"].as_i64()? > now && claims["nbf"].as_i64()? <= now).then_some(claims)
18915×
    }
190
}
191
192
/// `text` with every token this hub signed replaced by `***`: a job
193
/// that prints its identity does not leave it in the log.
1942×
pub fn mask(text: &str, kid: &str) -> String {
1952×
    if !text.contains("eyJ") {
1960×
        return text.to_string();
1972×
    }
1982069×
    let is_tok = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.';
1992×
    let mut out = String::with_capacity(text.len());
2002×
    let mut rest = text;
2015×
    while let Some(i) = rest.find("eyJ") {
2023×
        out.push_str(&rest[..i]);
2032069×
        let end = rest[i..].find(|c: char| !is_tok(c)).map(|e| i + e).unwrap_or(rest.len());
2043×
        let cand = &rest[i..end];
2053×
        let ours = cand
2063×
            .split('.')
2073×
            .next()
2083×
            .and_then(b64url_decode)
2093×
            .and_then(|h| serde_json::from_slice::<serde_json::Value>(&h).ok())
2103×
            .is_some_and(|h| h["kid"] == kid);
2113×
        if ours && cand.matches('.').count() == 2 {
2122×
            out.push_str("***");
2132×
        } else {
2141×
            out.push_str(cand);
2151×
        }
2163×
        rest = &rest[end..];
217
    }
2182×
    out.push_str(rest);
2192×
    out
2202×
}
221
22285×
fn b64url(bytes: &[u8]) -> String {
223
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
22485×
    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
2254964×
    for chunk in bytes.chunks(3) {
22614764×
        let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len()));
22719728×
        for i in 0..=chunk.len() {
22819728×
            out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
22919728×
        }
230
    }
23185×
    out
23285×
}
233
23444×
fn b64url_decode(s: &str) -> Option<Vec<u8>> {
23511325×
    let val = |c: u8| -> Option<u32> {
23611325×
        Some(match c {
2379850×
            b'A'..=b'Z' => (c - b'A') as u32,
2384666×
            b'a'..=b'z' => (c - b'a') as u32 + 26,
2391470×
            b'0'..=b'9' => (c - b'0') as u32 + 52,
24071×
            b'-' => 62,
24166×
            b'_' => 63,
2420×
            _ => return None,
243
        })
24411325×
    };
24544×
    let bytes = s.trim_end_matches('=').as_bytes();
24644×
    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
2472849×
    for chunk in bytes.chunks(4) {
2482849×
        if chunk.len() == 1 {
2490×
            return None;
2502849×
        }
2512849×
        let mut n = 0u32;
25211325×
        for (i, c) in chunk.iter().enumerate() {
25311325×
            n |= val(*c)? << (18 - 6 * i);
254
        }
2552849×
        out.push((n >> 16) as u8);
2562849×
        if chunk.len() > 2 {
2572819×
            out.push((n >> 8) as u8);
2582819×
        }
2592849×
        if chunk.len() > 3 {
2602808×
            out.push(n as u8);
2612808×
        }
262
    }
26344×
    Some(out)
26444×
}
265
266
/// `GET /.well-known/openid-configuration`.
2671×
pub async fn discovery(State(app): State<Shared>) -> Result<Response, ApiError> {
2681×
    let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
2691×
    Ok(([(axum::http::header::CACHE_CONTROL, "public, max-age=3600")], Json(issuer.discovery(app.config.base_url()))).into_response())
2701×
}
271
272
/// `GET /.well-known/jwks.json`.
2731×
pub async fn jwks(State(app): State<Shared>) -> Result<Response, ApiError> {
2741×
    let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
2751×
    Ok(([(axum::http::header::CACHE_CONTROL, "public, max-age=3600")], Json(issuer.jwks())).into_response())
2761×
}
277
278
#[cfg(test)]
279
mod tests {
280
    use super::*;
281
282
    #[test]
2831×
    fn tokens_verify_against_the_published_key_and_are_masked() {
2841×
        let dir = tempfile::tempdir().unwrap();
2851×
        let issuer = Issuer::load_or_create(dir.path()).unwrap();
2861×
        let again = Issuer::load_or_create(dir.path()).unwrap();
2871×
        assert_eq!(issuer.kid, again.kid, "the key is kept");
2881×
        let claims = JobClaims {
2891×
            issuer: "https://hub.example",
2901×
            audience: "sts.amazonaws.com",
2911×
            owner: "andy",
2921×
            name: "demo",
2931×
            visibility: "public",
2941×
            bookmark: "main",
2951×
            run_number: 7,
2961×
            job: "deploy",
2971×
            state: "ab".repeat(32).leak(),
2981×
            runner: "earth-1",
2991×
            actor: "andy",
3001×
            environment: Some("prod"),
3011×
            approved_by: Some("andy"),
3021×
            approval: Some("ab12"),
3031×
            now_ms: 1_800_000_000_000,
3041×
            ttl_s: 900,
3051×
        };
3061×
        let token = issuer.mint(&claims);
3071×
        assert_eq!(token.matches('.').count(), 2);
3081×
        let got = again.verify(&token, "sts.amazonaws.com", 1_800_000_500_000).expect("verifies");
3091×
        assert_eq!(got["sub"], "repo:andy/demo:bookmark:main");
3101×
        assert_eq!(got["run_number"], 7);
3111×
        assert_eq!(got["environment"], "prod");
3121×
        assert_eq!((got["approved_by"].as_str(), got["approval"].as_str()), (Some("andy"), Some("ab12")));
3131×
        let plain = issuer.mint(&JobClaims { environment: None, approved_by: None, approval: None, ..claims });
3141×
        assert!(again.verify(&plain, "sts.amazonaws.com", 1_800_000_500_000).unwrap().get("environment").is_none(), "only when approved");
3151×
        assert_eq!(got["exp"].as_i64().unwrap() - got["iat"].as_i64().unwrap(), 900);
3161×
        assert!(again.verify(&token, "other", 1_800_000_500_000).is_none(), "another audience");
3171×
        assert!(again.verify(&token, "sts.amazonaws.com", 1_800_001_000_000).is_none(), "expired");
3181×
        let other = Issuer::load_or_create(tempfile::tempdir().unwrap().path()).unwrap();
3191×
        assert!(other.verify(&token, "sts.amazonaws.com", 1_800_000_500_000).is_none(), "another key");
3201×
        let tampered = format!("{}x", token);
3211×
        assert!(again.verify(&tampered, "sts.amazonaws.com", 1_800_000_500_000).is_none());
3221×
        let jwks = issuer.jwks();
3231×
        assert_eq!(jwks["keys"][0]["kid"], issuer.kid.as_str());
3241×
        assert_eq!(b64url_decode(jwks["keys"][0]["e"].as_str().unwrap()).unwrap(), vec![1, 0, 1]);
3251×
        assert_eq!(mask(&format!("token={token} done eyJnot.a.token"), &issuer.kid), "token=*** done eyJnot.a.token");
3261×
        assert_eq!(b64url_decode(&b64url(b"hello?")).unwrap(), b"hello?");
3271×
    }
328
}