A job's identity: the hub is an OIDC issuer d1727c573f30
A job that declares identity "AUDIENCE" { mount-path … } gets a signed
RS256 token naming the repository, bookmark, run, job, state, runner
and actor, delivered through the secrets channel as a file; the key is
RSA 2048 in keys/oidc.pem, made on first use. Discovery and JWKS at
/.well-known so AWS, Google Cloud or Vault can trust it; tokens the hub
signed are masked in logs. closes #103
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 16:43 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
d1727c573f3046f749229c9532ca56dd76ee4fda5cfb1877f771b4e3424ae992- Depends on
- 1553bfc9ae53 18e381ea88c2 380996db84e5 42fa95540468 4ac01a732552 749d9a5fcd9f 7d2eb590051f 8f3d2ccfe6fc 93be9ce4653e a160b98fb922 b5668108487f b64e2537d702 d23464c0ac60 d4792b4a4629 e71846aa07b9
- Depended on by
- aeed63c00ad7 076c7a7c86b1 47e374020b59 67be8ac00c24 b644e1757711 3dd6582de060 35fea4237cc9 d7d182467382 6dbd999ad794 92a797b20912 617c2840446c 554ccacfc123 efd29a0033e8 d0fbea2eb46e
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 18 atoms, 1 tree operation, 10 files
Browse the tree at this change
added crates/hub/src/oidc.rs +287 −0
| 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 | + | } |
modified CHANGELOG.md +4 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Accounts elsewhere on a profile (#105).** Bluesky, Mastodon and GitHub under Settings › Profile, checked and normalised (a handle, `@user@instance` or a profile URL all work), shown on the profile with the service's icon as `rel="me"` links. |
|
| 8 | + | ||
| 9 | + | - **A job's identity: the hub is an OIDC issuer (#103).** A job that declares `identity "AUDIENCE" { mount-path … }` gets a signed token naming the repository, bookmark, run and job, delivered as a file like a secret; `/.well-known/openid-configuration` and `/.well-known/jwks.json` publish what AWS, Google Cloud or Vault need to trust it. Tokens are masked in logs. |
|
| 10 | + | ||
| 7 | 11 | - **Secrets for pipelines (#107).** Administration, organisation and repository settings hold secrets, typed once and sealed; a job gets only those its pipeline declares with `secret`, as files Flow mounts before it starts, never in the environment; their values are masked in the job's log as it arrives. |
|
| 8 | 12 | ||
| 9 | 13 | - **Variables for pipelines (#102).** Administration, organisation and repository settings hold variables for OxygenFlow jobs, inner levels overriding outer, each page showing what it inherits; a claim carries the repository's effective set and Flow adds them to the job's environment under `FLOW_*` and the pipeline's own. Not for secrets, which Flow mounts. |
|
| … | 99 unchanged lines |
modified Cargo.lock +2 −0
| … | 2241 unchanged lines |
||
| 2242 | 2242 | "regex", |
|
| 2243 | 2243 | "reqwest", |
|
| 2244 | 2244 | "ring", |
|
| 2245 | + | "rsa", |
|
| 2245 | 2246 | "rustls", |
|
| 2246 | 2247 | "serde", |
|
| 2247 | 2248 | "serde_json", |
|
| … | 586 unchanged lines |
||
| 2834 | 2835 | "pkcs1", |
|
| 2835 | 2836 | "pkcs8 0.10.2", |
|
| 2836 | 2837 | "rand_core 0.6.4", |
|
| 2838 | + | "sha2 0.10.9", |
|
| 2837 | 2839 | "signature 2.2.0", |
|
| 2838 | 2840 | "spki 0.7.3", |
|
| 2839 | 2841 | "subtle", |
|
| … | 1812 unchanged lines |
modified crates/hub/Cargo.toml +2 −0
| … | 69 unchanged lines |
||
| 70 | 70 | reqwest.workspace = true |
|
| 71 | 71 | # Seals assistant tokens at rest (hub spec §13.4); already in the tree through rustls. |
|
| 72 | 72 | ring = "0.17" |
|
| 73 | + | # RS256 for the OIDC job identity (hub spec §13.5); vendored already. |
|
| 74 | + | rsa = { version = "0.9", features = ["sha2"] } |
|
| 73 | 75 | futures.workspace = true |
|
| 74 | 76 | tokio-stream.workspace = true |
|
| 75 | 77 | ||
| … | 4 unchanged lines |
modified crates/hub/src/flow.rs +30 −0
| … | 513 unchanged lines |
||
| 514 | 514 | // at the top of the log and left out, so the job fails on the file. |
|
| 515 | 515 | let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect(); |
|
| 516 | 516 | let found = crate::secrets::for_job(&app, &row, &names).await?; |
|
| 517 | + | // The identities it asks for (#103): a token each, as a file. |
|
| 518 | + | if !claim.job.identities.is_empty() { |
|
| 519 | + | let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?; |
|
| 520 | + | for i in &claim.job.identities { |
|
| 521 | + | let token = issuer.mint(&crate::oidc::JobClaims { |
|
| 522 | + | issuer: app.config.base_url(), |
|
| 523 | + | audience: &i.audience, |
|
| 524 | + | owner: &row.owner, |
|
| 525 | + | name: &row.name, |
|
| 526 | + | visibility: &row.visibility, |
|
| 527 | + | bookmark: &run.bookmark, |
|
| 528 | + | run_number: run.number, |
|
| 529 | + | job: &job.name, |
|
| 530 | + | state: &run.state, |
|
| 531 | + | runner: &runner, |
|
| 532 | + | actor: &run.actor, |
|
| 533 | + | now_ms: db::now_ms(), |
|
| 534 | + | ttl_s: timeout_s, |
|
| 535 | + | }); |
|
| 536 | + | claim.secrets.push(flow_core::api::SecretInput { |
|
| 537 | + | name: format!("identity:{}", i.audience), |
|
| 538 | + | path: i.mount_path.clone(), |
|
| 539 | + | value: token, |
|
| 540 | + | }); |
|
| 541 | + | } |
|
| 542 | + | } |
|
| 517 | 543 | for s in &claim.job.secrets { |
|
| 518 | 544 | match found.iter().find(|(n, _)| *n == s.name) { |
|
| 519 | 545 | Some((name, value)) => { |
|
| … | 156 unchanged lines |
||
| 676 | 702 | } |
|
| 677 | 703 | _ => vec![], |
|
| 678 | 704 | }; |
|
| 705 | + | let kid = app.oidc.get().map(|i| i.kid.clone()); |
|
| 679 | 706 | for r in &mut batch.records { |
|
| 680 | 707 | if !values.is_empty() { |
|
| 681 | 708 | r.text = crate::secrets::mask(&r.text, &values); |
|
| 709 | + | } |
|
| 710 | + | if let Some(kid) = &kid { |
|
| 711 | + | r.text = crate::oidc::mask(&r.text, kid); |
|
| 682 | 712 | } |
|
| 683 | 713 | if r.text.len() > MAX_RECORD_TEXT { |
|
| 684 | 714 | let mut cut = MAX_RECORD_TEXT; |
|
| … | 428 unchanged lines |
modified crates/hub/src/lib.rs +12 −0
| … | 18 unchanged lines |
||
| 19 | 19 | pub mod maintenance; |
|
| 20 | 20 | pub mod mirror; |
|
| 21 | 21 | pub mod notify; |
|
| 22 | + | pub mod oidc; |
|
| 22 | 23 | pub mod protocol; |
|
| 23 | 24 | pub mod render; |
|
| 24 | 25 | pub mod repos; |
|
| … | 17 unchanged lines |
||
| 42 | 43 | pub assistant_key: assistants::SecretKey, |
|
| 43 | 44 | /// Seals Flow secrets at rest (hub spec §13.2, #107). |
|
| 44 | 45 | pub secret_key: assistants::SecretKey, |
|
| 46 | + | /// The OIDC issuer for jobs (hub spec §13.5), made on first use. |
|
| 47 | + | pub oidc: tokio::sync::OnceCell<oidc::Issuer>, |
|
| 45 | 48 | /// Which assistants are answering which proposals, and recent calls |
|
| 46 | 49 | /// per repository (hub spec §13.4). |
|
| 47 | 50 | pub assistant_gate: std::sync::Mutex<assistants::Gate>, |
|
| … | 6 unchanged lines |
||
| 54 | 57 | pub started: std::time::Instant, |
|
| 55 | 58 | } |
|
| 56 | 59 | ||
| 60 | + | impl App { |
|
| 61 | + | /// The OIDC issuer, its key loaded or made the first time it is asked for. |
|
| 62 | + | pub async fn oidc(&self) -> anyhow::Result<&oidc::Issuer> { |
|
| 63 | + | let keys = self.config.keys_dir(); |
|
| 64 | + | self.oidc.get_or_try_init(|| async move { tokio::task::spawn_blocking(move || oidc::Issuer::load_or_create(&keys)).await? }).await |
|
| 65 | + | } |
|
| 66 | + | } |
|
| 67 | + | ||
| 57 | 68 | pub type Shared = Arc<App>; |
|
| 58 | 69 | ||
| 59 | 70 | /// Open the data directory and build the application state. `init` must |
|
| … | 20 unchanged lines |
||
| 80 | 91 | cookie_key, |
|
| 81 | 92 | assistant_key, |
|
| 82 | 93 | secret_key, |
|
| 94 | + | oidc: tokio::sync::OnceCell::new(), |
|
| 83 | 95 | assistant_gate: std::sync::Mutex::new(assistants::Gate::default()), |
|
| 84 | 96 | events: events::Events::default(), |
|
| 85 | 97 | rooms: Arc::new(live::Rooms::default()), |
|
| … | 51 unchanged lines |
modified crates/hub/src/web/mod.rs +2 −0
| … | 56 unchanged lines |
||
| 57 | 57 | .route("/about", get(home::about)) |
|
| 58 | 58 | .route("/.well-known/oxyhub-key", get(home::hub_key)) |
|
| 59 | 59 | .route("/.well-known/oxyhub-ca.pem", get(home::hub_ca)) |
|
| 60 | + | .route("/.well-known/openid-configuration", get(crate::oidc::discovery)) |
|
| 61 | + | .route("/.well-known/jwks.json", get(crate::oidc::jwks)) |
|
| 60 | 62 | .route("/style.css", get(statics::style)) |
|
| 61 | 63 | .route("/favicon.svg", get(statics::favicon)) |
|
| 62 | 64 | .route("/favicon.ico", get(statics::favicon)) |
|
| … | 580 unchanged lines |
modified crates/hub/tests/flow.rs +66 −0
| … | 1922 unchanged lines |
||
| 1923 | 1923 | assert!(texts.contains(&"token=***") && texts.contains(&"b64 *** end"), "{texts:?}"); |
|
| 1924 | 1924 | assert!(!texts.iter().any(|t| t.contains("hunter22")), "{texts:?}"); |
|
| 1925 | 1925 | } |
|
| 1926 | + | ||
| 1927 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1928 | + | async fn a_job_s_identity_is_a_signed_token_the_discovery_document_lets_anyone_verify() { |
|
| 1929 | + | let h = hub().await; |
|
| 1930 | + | let app = &h.app; |
|
| 1931 | + | let (st, disc) = call(app, "GET", "/.well-known/openid-configuration", None, None).await; |
|
| 1932 | + | assert_eq!(st, StatusCode::OK, "{disc}"); |
|
| 1933 | + | let disc: serde_json::Value = serde_json::from_str(&disc).unwrap(); |
|
| 1934 | + | assert_eq!(disc["issuer"], h.base.as_str()); |
|
| 1935 | + | assert_eq!(disc["jwks_uri"], format!("{}/.well-known/jwks.json", h.base)); |
|
| 1936 | + | let (st, jwks) = call(app, "GET", "/.well-known/jwks.json", None, None).await; |
|
| 1937 | + | assert_eq!(st, StatusCode::OK, "{jwks}"); |
|
| 1938 | + | let jwks: serde_json::Value = serde_json::from_str(&jwks).unwrap(); |
|
| 1939 | + | assert_eq!(jwks["keys"][0]["alg"], "RS256"); |
|
| 1940 | + | let kid = jwks["keys"][0]["kid"].as_str().unwrap().to_string(); |
|
| 1941 | + | ||
| 1942 | + | create(app, "andy", "demo").await; |
|
| 1943 | + | let url = format!("{}/andy/demo", h.base); |
|
| 1944 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1945 | + | let c = client(&url, work.path()); |
|
| 1946 | + | write( |
|
| 1947 | + | work.path(), |
|
| 1948 | + | ".flow/pipeline.kdl", |
|
| 1949 | + | &pipeline("job \"deploy\" {\n sandbox \"alpine\"\n identity \"sts.amazonaws.com\" {\n mount-path \"/run/secrets/aws-token\"\n }\n exec \"deploy\"\n}\n"), |
|
| 1950 | + | ); |
|
| 1951 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 1952 | + | c.snapshot("test").unwrap(); |
|
| 1953 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 1954 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1955 | + | let mut deploy = None; |
|
| 1956 | + | for _ in 0..50 { |
|
| 1957 | + | let Some(got) = claim(app).await else { |
|
| 1958 | + | tokio::time::sleep(std::time::Duration::from_millis(100)).await; |
|
| 1959 | + | continue; |
|
| 1960 | + | }; |
|
| 1961 | + | if got["job"]["name"] == "deploy" { |
|
| 1962 | + | deploy = Some(got); |
|
| 1963 | + | break; |
|
| 1964 | + | } |
|
| 1965 | + | } |
|
| 1966 | + | let got = deploy.expect("the deploy job"); |
|
| 1967 | + | let secrets = got["secrets"].as_array().expect("secrets"); |
|
| 1968 | + | assert_eq!(secrets.len(), 1); |
|
| 1969 | + | assert_eq!( |
|
| 1970 | + | (secrets[0]["name"].as_str(), secrets[0]["path"].as_str()), |
|
| 1971 | + | (Some("identity:sts.amazonaws.com"), Some("/run/secrets/aws-token")) |
|
| 1972 | + | ); |
|
| 1973 | + | let token = secrets[0]["value"].as_str().unwrap().to_string(); |
|
| 1974 | + | let issuer = app.oidc().await.unwrap(); |
|
| 1975 | + | assert_eq!(issuer.kid, kid, "the key the JWKS publishes signed it"); |
|
| 1976 | + | let claims = issuer.verify(&token, "sts.amazonaws.com", db::now_ms()).expect("verifies"); |
|
| 1977 | + | assert_eq!(claims["sub"], "repo:andy/demo:bookmark:main"); |
|
| 1978 | + | assert_eq!(claims["repository"], "andy/demo"); |
|
| 1979 | + | assert_eq!(claims["job"], "deploy"); |
|
| 1980 | + | assert_eq!(claims["run_number"], 1); |
|
| 1981 | + | assert_eq!(claims["runner"], "test-1"); |
|
| 1982 | + | assert_eq!(claims["iss"], h.base.as_str()); |
|
| 1983 | + | assert!(claims["state"].as_str().unwrap().len() == 64); |
|
| 1984 | + | // A job that prints its token leaves *** in the log. |
|
| 1985 | + | let id = got["job_id"].as_i64().unwrap(); |
|
| 1986 | + | let records = serde_json::json!({ "records": [{ "t": 1, "stream": "out", "text": format!("TOKEN={token} end") }] }); |
|
| 1987 | + | let (st, _) = call(app, "POST", &format!("/api/v1/flow/jobs/{id}/log"), Some("runner-secret"), Some(records.to_string())).await; |
|
| 1988 | + | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 1989 | + | let log = db::flow::log(&app.db, id, 0, 100).await.unwrap(); |
|
| 1990 | + | assert!(log.iter().any(|l| l.text == "TOKEN=*** end"), "{log:?}"); |
|
| 1991 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +10 −1
| … | 240 unchanged lines |
||
| 241 | 241 | |---|---| |
|
| 242 | 242 | | `/` | Signed in: dashboard (your repositories, proposals awaiting your review, assigned issues, recent activity). Anonymous: explore. | |
|
| 243 | 243 | | `/explore` | Every repository the viewer may see; `q` filters by name, description or topic, `topic` by one topic, `sort` is `activity` (default), `stars` or `name`; each row shows its topics and stars. Topics are set under repository settings (letters, digits and dashes, at most 20); anyone signed in can star a repository, and the overview shows star and watcher counts | |
|
| 244 | - | | `/{owner}` | A person: identicon, display name, bio, location, website, linked signing keys, organisations, repositories the viewer can see, and their recent activity (never the email). An organisation: identicon, description, members with roles, teams with sizes, repositories, recent activity in them. `/avatar/{name}` serves the account's or organisation's picture: an uploaded photo or logo when there is one, otherwise the identicon (SVG, a mirrored 5×5 pattern and hue from BLAKE3 of the lower-cased name). Pictures are uploaded under Settings › Profile, or an organisation's settings by its owners: PNG, JPEG or WebP recognised by content (not by name), at most 5 MiB and 8 000 pixels a side, decoded, centre-cropped to a square and re-encoded at 400×400 (PNG when it has transparency, JPEG otherwise), so no uploaded bytes are ever served. Removing the picture brings the identicon back. Pictures are cached for five minutes and carry an ETag. | |
|
| 244 | + | | `/{owner}` | A person: identicon, display name, bio, location, website, accounts elsewhere (a Bluesky handle, a Mastodon address and a GitHub username, each checked and normalised under Settings › Profile and shown with the service's icon as a `rel="me"` link; oxygenhub#105), linked signing keys, organisations, repositories the viewer can see, and their recent activity (never the email). An organisation: identicon, description, members with roles, teams with sizes, repositories, recent activity in them. `/avatar/{name}` serves the account's or organisation's picture: an uploaded photo or logo when there is one, otherwise the identicon (SVG, a mirrored 5×5 pattern and hue from BLAKE3 of the lower-cased name). Pictures are uploaded under Settings › Profile, or an organisation's settings by its owners: PNG, JPEG or WebP recognised by content (not by name), at most 5 MiB and 8 000 pixels a side, decoded, centre-cropped to a square and re-encoded at 400×400 (PNG when it has transparency, JPEG otherwise), so no uploaded bytes are ever served. Removing the picture brings the identicon back. Pictures are cached for five minutes and carry an ETag. | |
|
| 245 | 245 | | `/{owner}/{name}` | Repository overview (§6.4); while empty, a quickstart with the exact commands for this hub (remote, certificate, token, push; start from nothing; import from Git) | |
|
| 246 | 246 | | `/{owner}/{name}/fork`, `/{owner}/{name}/forks` | Fork under an owner the viewer may create repositories for (same visibility, own repository id, objects hard-linked, bookmarks copied, policy not copied); the list of forks the viewer can see. The overview shows "forked from", the fork count, and on a fork a link that opens a proposal upstream with `owner/name:bookmark` as its source | |
|
| 247 | 247 | | `/{owner}/{name}/tree/{ref}/{path}` | Directory listing at a bookmark, tag, change or state (§6.5). A `{ref}` is a bookmark name, a tag name, a change hash or unique prefix, or a state: full change hashes joined with `+` (`a1…+b2…`), the state those heads make, which is how a state with several heads is addressed permanently | |
|
| … | 302 unchanged lines |
||
| 550 | 550 | - **Limits.** One request per assistant per proposal at a time, 30 assistant calls per repository per hour (in memory), a 60-second call timeout, answers kept to 64 KiB, and a context budget (characters) per assistant. |
|
| 551 | 551 | - **API.** `POST /repos/{o}/{n}/proposals/{n}/review-requests {"assistant": NAME}` (the author or a writer; 202 when started, 409 when that assistant is already answering or the repository is over its hourly limit, 422 for a name the repository cannot use); reviews carry `via`. Settings endpoints are planned. Hub actions by an assistant are attributed "ASSISTANT via WHO" everywhere, so a project can require that a human has approved regardless of what an assistant said. |
|
| 552 | 552 | ||
| 553 | + | ### 13.5 A job's identity: the hub as an OIDC issuer (done 2026-10-04) |
|
| 554 | + | ||
| 555 | + | A job that declares `identity "AUDIENCE" { mount-path "/run/secrets/…" }` (OxygenFlow m0 §3) gets a signed JWT so a cloud, a vault or any service that trusts this hub can let it in without a long-lived secret (oxygenhub#103). |
|
| 556 | + | ||
| 557 | + | - **Issuer.** The hub's `base_url`. `GET /.well-known/openid-configuration` publishes the discovery document (`issuer`, `jwks_uri`, RS256, the claims below) and `GET /.well-known/jwks.json` the public key, both cacheable for an hour. The key is RSA 2048 in `keys/oidc.pem` (PKCS#8, mode 0600), made the first time it is needed; its `kid` is the first 16 hex of BLAKE3 over the modulus. Rotation is by replacing the file and restarting; tokens signed by the old key then fail, which a job limit of hours makes harmless. |
|
| 558 | + | - **Token.** Minted at claim time, one per identity, and delivered through the same channel as secrets: the claim's `secrets` carry it as `identity:AUDIENCE` at the mount path, where Flow writes it 0400 on the in-memory mount. Header `{"alg":"RS256","typ":"JWT","kid"}`; claims `iss`, `sub` = `repo:OWNER/NAME:bookmark:BOOKMARK` (what a trust policy matches), `aud`, `iat`, `nbf` (30 s of skew), `exp` = the job's time limit from now, at most six hours, `jti`, and `repository`, `repository_owner`, `repository_visibility`, `bookmark`, `run_number`, `job`, `state` (the state hash), `runner`, `actor` (who started the run). A local `flow run` has no issuer and says the file will be missing. |
|
| 559 | + | - **Masking.** Log intake replaces any token this hub signed (recognised by its `kid`) with `***`, as it does secret values. |
|
| 560 | + | - **Trust, for example.** AWS: an IAM OIDC provider with the hub's URL and audience `sts.amazonaws.com`, a role whose trust policy matches `sub` `repo:andymccall/metalgear:bookmark:main`; the job then calls `aws sts assume-role-with-web-identity --web-identity-token file:///run/secrets/aws-token`. Google: a workload identity pool provider with the same issuer, mapping `assertion.sub`. HashiCorp Vault: the JWT auth method with `bound_audiences` and `bound_claims` on `repository`. |
|
| 561 | + | ||
| 553 | 562 | ## 14. Git interoperability |
|
| 554 | 563 | ||
| 555 | 564 | A repository may enable a **Git mirror**: on every landing on selected bookmarks the hub runs `oxy git export` into `<data>/mirrors/<owner>/<name>.git` and serves it read-only over Git's smart HTTP protocol at `/{owner}/{name}.git` (implemented with the `git` binary's `http-backend` behind the hub's authorization, since reimplementing pack negotiation is not the product). This lets GitHub Actions runners, IDEs and tooling that only speak Git read hosted Oxygen repositories. Pushing to a mirror is refused with a message pointing at `oxy`. |
|
| … | 144 unchanged lines |