OXYGENHUB
# 5ef6cf72cd19 oxygenhub / crates / hub / src / oidc.rs
12.2 KiB · Rust · last changed feat: keyless GCS backups, and TEST (closes #169) 22 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
    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
    /// A signed token for the hub itself acting as `subject` (a backup's
139
    /// `backup:OWNER`, oxygenhub#169), for `audience`, living `ttl_s`.
140
    pub fn mint_for(&self, issuer: &str, subject: &str, audience: &str, now_ms: i64, ttl_s: u64) -> String {
141
        let iat = now_ms / 1000;
142
        let header = serde_json::json!({ "alg": "RS256", "typ": "JWT", "kid": self.kid });
143
        let payload = serde_json::json!({
144
            "iss": issuer,
145
            "sub": subject,
146
            "aud": audience,
147
            "iat": iat,
148
            "nbf": iat - 30,
149
            "exp": iat + ttl_s.clamp(60, MAX_TTL_S) as i64,
150
            "jti": crate::auth::random_hex(16),
151
        });
152
        let signing_input = format!("{}.{}", b64url(header.to_string().as_bytes()), b64url(payload.to_string().as_bytes()));
153
        let sig = self.signing.sign(signing_input.as_bytes());
154
        format!("{signing_input}.{}", b64url(&sig.to_bytes()))
155
    }
156
157
    /// The claims of a token this hub signed, when its signature holds,
158
    /// it is for `audience` and it has not expired at `now_ms`.
159
    pub fn verify(&self, token: &str, audience: &str, now_ms: i64) -> Option<serde_json::Value> {
160
        let mut parts = token.split('.');
161
        let (h, p, s) = (parts.next()?, parts.next()?, parts.next()?);
162
        if parts.next().is_some() {
163
            return None;
164
        }
165
        let header: serde_json::Value = serde_json::from_slice(&b64url_decode(h)?).ok()?;
166
        if header["alg"] != "RS256" || header["kid"] != self.kid.as_str() {
167
            return None;
168
        }
169
        let sig = rsa::pkcs1v15::Signature::try_from(b64url_decode(s)?.as_slice()).ok()?;
170
        self.verifying.verify(format!("{h}.{p}").as_bytes(), &sig).ok()?;
171
        let claims: serde_json::Value = serde_json::from_slice(&b64url_decode(p)?).ok()?;
172
        let now = now_ms / 1000;
173
        (claims["aud"] == audience && claims["exp"].as_i64()? > now && claims["nbf"].as_i64()? <= now).then_some(claims)
174
    }
175
}
176
177
/// `text` with every token this hub signed replaced by `***`: a job
178
/// that prints its identity does not leave it in the log.
179
pub fn mask(text: &str, kid: &str) -> String {
180
    if !text.contains("eyJ") {
181
        return text.to_string();
182
    }
183
    let is_tok = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.';
184
    let mut out = String::with_capacity(text.len());
185
    let mut rest = text;
186
    while let Some(i) = rest.find("eyJ") {
187
        out.push_str(&rest[..i]);
188
        let end = rest[i..].find(|c: char| !is_tok(c)).map(|e| i + e).unwrap_or(rest.len());
189
        let cand = &rest[i..end];
190
        let ours = cand
191
            .split('.')
192
            .next()
193
            .and_then(b64url_decode)
194
            .and_then(|h| serde_json::from_slice::<serde_json::Value>(&h).ok())
195
            .is_some_and(|h| h["kid"] == kid);
196
        if ours && cand.matches('.').count() == 2 {
197
            out.push_str("***");
198
        } else {
199
            out.push_str(cand);
200
        }
201
        rest = &rest[end..];
202
    }
203
    out.push_str(rest);
204
    out
205
}
206
207
fn b64url(bytes: &[u8]) -> String {
208
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
209
    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
210
    for chunk in bytes.chunks(3) {
211
        let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len()));
212
        for i in 0..=chunk.len() {
213
            out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
214
        }
215
    }
216
    out
217
}
218
219
fn b64url_decode(s: &str) -> Option<Vec<u8>> {
220
    let val = |c: u8| -> Option<u32> {
221
        Some(match c {
222
            b'A'..=b'Z' => (c - b'A') as u32,
223
            b'a'..=b'z' => (c - b'a') as u32 + 26,
224
            b'0'..=b'9' => (c - b'0') as u32 + 52,
225
            b'-' => 62,
226
            b'_' => 63,
227
            _ => return None,
228
        })
229
    };
230
    let bytes = s.trim_end_matches('=').as_bytes();
231
    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
232
    for chunk in bytes.chunks(4) {
233
        if chunk.len() == 1 {
234
            return None;
235
        }
236
        let mut n = 0u32;
237
        for (i, c) in chunk.iter().enumerate() {
238
            n |= val(*c)? << (18 - 6 * i);
239
        }
240
        out.push((n >> 16) as u8);
241
        if chunk.len() > 2 {
242
            out.push((n >> 8) as u8);
243
        }
244
        if chunk.len() > 3 {
245
            out.push(n as u8);
246
        }
247
    }
248
    Some(out)
249
}
250
251
/// `GET /.well-known/openid-configuration`.
252
pub async fn discovery(State(app): State<Shared>) -> Result<Response, ApiError> {
253
    let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
254
    Ok(([(axum::http::header::CACHE_CONTROL, "public, max-age=3600")], Json(issuer.discovery(app.config.base_url()))).into_response())
255
}
256
257
/// `GET /.well-known/jwks.json`.
258
pub async fn jwks(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.jwks())).into_response())
261
}
262
263
#[cfg(test)]
264
mod tests {
265
    use super::*;
266
267
    #[test]
268
    fn tokens_verify_against_the_published_key_and_are_masked() {
269
        let dir = tempfile::tempdir().unwrap();
270
        let issuer = Issuer::load_or_create(dir.path()).unwrap();
271
        let again = Issuer::load_or_create(dir.path()).unwrap();
272
        assert_eq!(issuer.kid, again.kid, "the key is kept");
273
        let claims = JobClaims {
274
            issuer: "https://hub.example",
275
            audience: "sts.amazonaws.com",
276
            owner: "andy",
277
            name: "demo",
278
            visibility: "public",
279
            bookmark: "main",
280
            run_number: 7,
281
            job: "deploy",
282
            state: "ab".repeat(32).leak(),
283
            runner: "earth-1",
284
            actor: "andy",
285
            now_ms: 1_800_000_000_000,
286
            ttl_s: 900,
287
        };
288
        let token = issuer.mint(&claims);
289
        assert_eq!(token.matches('.').count(), 2);
290
        let got = again.verify(&token, "sts.amazonaws.com", 1_800_000_500_000).expect("verifies");
291
        assert_eq!(got["sub"], "repo:andy/demo:bookmark:main");
292
        assert_eq!(got["run_number"], 7);
293
        assert_eq!(got["exp"].as_i64().unwrap() - got["iat"].as_i64().unwrap(), 900);
294
        assert!(again.verify(&token, "other", 1_800_000_500_000).is_none(), "another audience");
295
        assert!(again.verify(&token, "sts.amazonaws.com", 1_800_001_000_000).is_none(), "expired");
296
        let other = Issuer::load_or_create(tempfile::tempdir().unwrap().path()).unwrap();
297
        assert!(other.verify(&token, "sts.amazonaws.com", 1_800_000_500_000).is_none(), "another key");
298
        let tampered = format!("{}x", token);
299
        assert!(again.verify(&tampered, "sts.amazonaws.com", 1_800_000_500_000).is_none());
300
        let jwks = issuer.jwks();
301
        assert_eq!(jwks["keys"][0]["kid"], issuer.kid.as_str());
302
        assert_eq!(b64url_decode(jwks["keys"][0]["e"].as_str().unwrap()).unwrap(), vec![1, 0, 1]);
303
        assert_eq!(mask(&format!("token={token} done eyJnot.a.token"), &issuer.kid), "token=*** done eyJnot.a.token");
304
        assert_eq!(b64url_decode(&b64url(b"hello?")).unwrap(), b"hello?");
305
    }
306
}