OXYGENHUB

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