OXYGENHUB
# b6dfcb61cf5e oxygenhub / crates / hub / src / oidc.rs
13.4 KiB · Rust · last changed Sealed approvals: a gate released only by a signature of the approver's key 20 days ago source blame history raw
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).
63
    pub fn load_or_create(keys: &Path) -> anyhow::Result<Issuer> {
64
        std::fs::create_dir_all(keys)?;
65
        let path = keys.join("oidc.pem");
66
        let key = match std::fs::read_to_string(&path) {
67
            Ok(pem) => RsaPrivateKey::from_pkcs8_pem(&pem).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?,
68
            Err(_) => {
69
                let key = RsaPrivateKey::new(&mut rand::rngs::OsRng, 2048)?;
70
                let pem = key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)?;
71
                std::fs::write(&path, pem.as_bytes())?;
72
                #[cfg(unix)]
73
                {
74
                    use std::os::unix::fs::PermissionsExt;
75
                    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
76
                }
77
                key
78
            }
79
        };
80
        Ok(Issuer::from_key(key))
81
    }
82
83
    fn from_key(key: RsaPrivateKey) -> Issuer {
84
        let public = key.to_public_key();
85
        let n_bytes = public.n().to_bytes_be();
86
        let kid = blake3::hash(&n_bytes).to_hex()[..16].to_string();
87
        Issuer {
88
            n: b64url(&n_bytes),
89
            e: b64url(&public.e().to_bytes_be()),
90
            verifying: VerifyingKey::new(public),
91
            signing: SigningKey::new(key),
92
            kid,
93
        }
94
    }
95
96
    /// `/.well-known/openid-configuration`.
97
    pub fn discovery(&self, issuer: &str) -> serde_json::Value {
98
        serde_json::json!({
99
            "issuer": issuer,
100
            "jwks_uri": format!("{issuer}/.well-known/jwks.json"),
101
            "response_types_supported": ["id_token"],
102
            "subject_types_supported": ["public"],
103
            "id_token_signing_alg_values_supported": ["RS256"],
104
            "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"],
106
            "scopes_supported": ["openid"],
107
        })
108
    }
109
110
    /// `/.well-known/jwks.json`.
111
    pub fn jwks(&self) -> serde_json::Value {
112
        serde_json::json!({ "keys": [{ "kty": "RSA", "use": "sig", "alg": "RS256", "kid": self.kid, "n": self.n, "e": self.e }] })
113
    }
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.
117
    pub fn mint(&self, c: &JobClaims<'_>) -> String {
118
        let iat = c.now_ms / 1000;
119
        let exp = iat + c.ttl_s.clamp(60, MAX_TTL_S) as i64;
120
        let header = serde_json::json!({ "alg": "RS256", "typ": "JWT", "kid": self.kid });
121
        let mut payload = serde_json::json!({
122
            "iss": c.issuer,
123
            "sub": format!("repo:{}/{}:bookmark:{}", c.owner, c.name, c.bookmark),
124
            "aud": c.audience,
125
            "iat": iat,
126
            "nbf": iat - 30,
127
            "exp": exp,
128
            "jti": crate::auth::random_hex(16),
129
            "repository": format!("{}/{}", c.owner, c.name),
130
            "repository_owner": c.owner,
131
            "repository_visibility": c.visibility,
132
            "bookmark": c.bookmark,
133
            "run_number": c.run_number,
134
            "job": c.job,
135
            "state": c.state,
136
            "runner": c.runner,
137
            "actor": c.actor,
138
        });
139
        if let Some(e) = c.environment {
140
            payload["environment"] = serde_json::Value::from(e);
141
        }
142
        if let Some(a) = c.approved_by {
143
            payload["approved_by"] = serde_json::Value::from(a);
144
        }
145
        if let Some(a) = c.approval {
146
            payload["approval"] = serde_json::Value::from(a);
147
        }
148
        let signing_input = format!("{}.{}", b64url(header.to_string().as_bytes()), b64url(payload.to_string().as_bytes()));
149
        let sig = self.signing.sign(signing_input.as_bytes());
150
        format!("{signing_input}.{}", b64url(&sig.to_bytes()))
151
    }
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`.
155
    pub fn mint_for(&self, issuer: &str, subject: &str, audience: &str, now_ms: i64, ttl_s: u64) -> String {
156
        let iat = now_ms / 1000;
157
        let header = serde_json::json!({ "alg": "RS256", "typ": "JWT", "kid": self.kid });
158
        let payload = serde_json::json!({
159
            "iss": issuer,
160
            "sub": subject,
161
            "aud": audience,
162
            "iat": iat,
163
            "nbf": iat - 30,
164
            "exp": iat + ttl_s.clamp(60, MAX_TTL_S) as i64,
165
            "jti": crate::auth::random_hex(16),
166
        });
167
        let signing_input = format!("{}.{}", b64url(header.to_string().as_bytes()), b64url(payload.to_string().as_bytes()));
168
        let sig = self.signing.sign(signing_input.as_bytes());
169
        format!("{signing_input}.{}", b64url(&sig.to_bytes()))
170
    }
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`.
174
    pub fn verify(&self, token: &str, audience: &str, now_ms: i64) -> Option<serde_json::Value> {
175
        let mut parts = token.split('.');
176
        let (h, p, s) = (parts.next()?, parts.next()?, parts.next()?);
177
        if parts.next().is_some() {
178
            return None;
179
        }
180
        let header: serde_json::Value = serde_json::from_slice(&b64url_decode(h)?).ok()?;
181
        if header["alg"] != "RS256" || header["kid"] != self.kid.as_str() {
182
            return None;
183
        }
184
        let sig = rsa::pkcs1v15::Signature::try_from(b64url_decode(s)?.as_slice()).ok()?;
185
        self.verifying.verify(format!("{h}.{p}").as_bytes(), &sig).ok()?;
186
        let claims: serde_json::Value = serde_json::from_slice(&b64url_decode(p)?).ok()?;
187
        let now = now_ms / 1000;
188
        (claims["aud"] == audience && claims["exp"].as_i64()? > now && claims["nbf"].as_i64()? <= now).then_some(claims)
189
    }
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.
194
pub fn mask(text: &str, kid: &str) -> String {
195
    if !text.contains("eyJ") {
196
        return text.to_string();
197
    }
198
    let is_tok = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.';
199
    let mut out = String::with_capacity(text.len());
200
    let mut rest = text;
201
    while let Some(i) = rest.find("eyJ") {
202
        out.push_str(&rest[..i]);
203
        let end = rest[i..].find(|c: char| !is_tok(c)).map(|e| i + e).unwrap_or(rest.len());
204
        let cand = &rest[i..end];
205
        let ours = cand
206
            .split('.')
207
            .next()
208
            .and_then(b64url_decode)
209
            .and_then(|h| serde_json::from_slice::<serde_json::Value>(&h).ok())
210
            .is_some_and(|h| h["kid"] == kid);
211
        if ours && cand.matches('.').count() == 2 {
212
            out.push_str("***");
213
        } else {
214
            out.push_str(cand);
215
        }
216
        rest = &rest[end..];
217
    }
218
    out.push_str(rest);
219
    out
220
}
221
222
fn b64url(bytes: &[u8]) -> String {
223
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
224
    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
225
    for chunk in bytes.chunks(3) {
226
        let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len()));
227
        for i in 0..=chunk.len() {
228
            out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
229
        }
230
    }
231
    out
232
}
233
234
fn b64url_decode(s: &str) -> Option<Vec<u8>> {
235
    let val = |c: u8| -> Option<u32> {
236
        Some(match c {
237
            b'A'..=b'Z' => (c - b'A') as u32,
238
            b'a'..=b'z' => (c - b'a') as u32 + 26,
239
            b'0'..=b'9' => (c - b'0') as u32 + 52,
240
            b'-' => 62,
241
            b'_' => 63,
242
            _ => return None,
243
        })
244
    };
245
    let bytes = s.trim_end_matches('=').as_bytes();
246
    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
247
    for chunk in bytes.chunks(4) {
248
        if chunk.len() == 1 {
249
            return None;
250
        }
251
        let mut n = 0u32;
252
        for (i, c) in chunk.iter().enumerate() {
253
            n |= val(*c)? << (18 - 6 * i);
254
        }
255
        out.push((n >> 16) as u8);
256
        if chunk.len() > 2 {
257
            out.push((n >> 8) as u8);
258
        }
259
        if chunk.len() > 3 {
260
            out.push(n as u8);
261
        }
262
    }
263
    Some(out)
264
}
265
266
/// `GET /.well-known/openid-configuration`.
267
pub async fn discovery(State(app): State<Shared>) -> Result<Response, ApiError> {
268
    let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
269
    Ok(([(axum::http::header::CACHE_CONTROL, "public, max-age=3600")], Json(issuer.discovery(app.config.base_url()))).into_response())
270
}
271
272
/// `GET /.well-known/jwks.json`.
273
pub async fn jwks(State(app): State<Shared>) -> Result<Response, ApiError> {
274
    let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
275
    Ok(([(axum::http::header::CACHE_CONTROL, "public, max-age=3600")], Json(issuer.jwks())).into_response())
276
}
277
278
#[cfg(test)]
279
mod tests {
280
    use super::*;
281
282
    #[test]
283
    fn tokens_verify_against_the_published_key_and_are_masked() {
284
        let dir = tempfile::tempdir().unwrap();
285
        let issuer = Issuer::load_or_create(dir.path()).unwrap();
286
        let again = Issuer::load_or_create(dir.path()).unwrap();
287
        assert_eq!(issuer.kid, again.kid, "the key is kept");
288
        let claims = JobClaims {
289
            issuer: "https://hub.example",
290
            audience: "sts.amazonaws.com",
291
            owner: "andy",
292
            name: "demo",
293
            visibility: "public",
294
            bookmark: "main",
295
            run_number: 7,
296
            job: "deploy",
297
            state: "ab".repeat(32).leak(),
298
            runner: "earth-1",
299
            actor: "andy",
300
            environment: Some("prod"),
301
            approved_by: Some("andy"),
302
            approval: Some("ab12"),
303
            now_ms: 1_800_000_000_000,
304
            ttl_s: 900,
305
        };
306
        let token = issuer.mint(&claims);
307
        assert_eq!(token.matches('.').count(), 2);
308
        let got = again.verify(&token, "sts.amazonaws.com", 1_800_000_500_000).expect("verifies");
309
        assert_eq!(got["sub"], "repo:andy/demo:bookmark:main");
310
        assert_eq!(got["run_number"], 7);
311
        assert_eq!(got["environment"], "prod");
312
        assert_eq!((got["approved_by"].as_str(), got["approval"].as_str()), (Some("andy"), Some("ab12")));
313
        let plain = issuer.mint(&JobClaims { environment: None, approved_by: None, approval: None, ..claims });
314
        assert!(again.verify(&plain, "sts.amazonaws.com", 1_800_000_500_000).unwrap().get("environment").is_none(), "only when approved");
315
        assert_eq!(got["exp"].as_i64().unwrap() - got["iat"].as_i64().unwrap(), 900);
316
        assert!(again.verify(&token, "other", 1_800_000_500_000).is_none(), "another audience");
317
        assert!(again.verify(&token, "sts.amazonaws.com", 1_800_001_000_000).is_none(), "expired");
318
        let other = Issuer::load_or_create(tempfile::tempdir().unwrap().path()).unwrap();
319
        assert!(other.verify(&token, "sts.amazonaws.com", 1_800_000_500_000).is_none(), "another key");
320
        let tampered = format!("{}x", token);
321
        assert!(again.verify(&tampered, "sts.amazonaws.com", 1_800_000_500_000).is_none());
322
        let jwks = issuer.jwks();
323
        assert_eq!(jwks["keys"][0]["kid"], issuer.kid.as_str());
324
        assert_eq!(b64url_decode(jwks["keys"][0]["e"].as_str().unwrap()).unwrap(), vec![1, 0, 1]);
325
        assert_eq!(mask(&format!("token={token} done eyJnot.a.token"), &issuer.kid), "token=*** done eyJnot.a.token");
326
        assert_eq!(b64url_decode(&b64url(b"hello?")).unwrap(), b"hello?");
327
    }
328
}