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