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