Skip to content
398 linesCodeBlameRaw
1//! OIDC tokens for workflow jobs: g1t as an OpenID Connect issuer, so a
2//! job with `permissions: id-token: write` can trade a short-lived token
3//! for a cloud provider's credentials instead of keeping a long-lived key
4//! in a secret.
5//!
6//! - The issuer is `{API}/actions/oidc` (`https://api.g1t.sh/actions/oidc`
7//! hosted): its discovery document at
8//! `/.well-known/openid-configuration` under it, and its keys at
9//! `/.well-known/jwks`. No host of its own: the API's.
10//! - A job asks `GET {issuer}/token?api-version=2.0&audience=…` with its
11//! runtime token (`ACTIONS_ID_TOKEN_REQUEST_URL` and `…_TOKEN`, as the
12//! toolkit's `core.getIDToken` reads them) and gets `{ "value": jwt }`.
13//! - Tokens are RS256, good for five minutes, with GitHub's claims (the
14//! actions service decides them and whether the job may have one).
15//! - The signing key is the Worker secret `ACTIONS_OIDC_KEY`, an RSA
16//! private key in PEM (PKCS#8 or PKCS#1). `ACTIONS_OIDC_KEY_PREVIOUS`,
17//! while it is set, is published too, so tokens it signed still verify
18//! while the new key takes over. Each key's `kid` is its RFC 7638
19//! thumbprint. docs.g1t.sh/guides/deploy-to-cloudflare/ ("OIDC tokens
20//! for workflow jobs") says how to make and rotate them.
21
22use base64::Engine;
23use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
24use g1t_contracts::actions::RuntimeAuthArgs;
25use g1t_contracts::{FailureCode, Outcome};
26use g1t_kit::js;
27use serde_json::{Value, json};
28use sha2::{Digest, Sha256};
29use worker::js_sys::{self, Uint8Array};
30use worker::{Env, Error, Request, Response, Result};
31
32use crate::operations::Services;
33
34/// How long a token is good for.
35const LIFETIME_SECONDS: u64 = 5 * 60;
36pub const KEY_SECRET: &str = "ACTIONS_OIDC_KEY";
37pub const PREVIOUS_KEY_SECRET: &str = "ACTIONS_OIDC_KEY_PREVIOUS";
38
39/// The issuer, under the API's address.
40pub fn issuer(api: &str) -> String {
41 format!("{api}/actions/oidc")
42}
43
44/// The OpenID Provider configuration, as relying parties fetch it.
45pub fn discovery(api: &str) -> Value {
46 let issuer = issuer(api);
47 json!({
48 "issuer": issuer,
49 "jwks_uri": format!("{issuer}/.well-known/jwks"),
50 "subject_types_supported": ["public", "pairwise"],
51 "response_types_supported": ["id_token"],
52 "claims_supported": [
53 "sub", "aud", "exp", "iat", "iss", "jti", "nbf", "ref", "sha", "repository", "repository_id", "repository_owner",
54 "repository_owner_id", "repository_visibility", "run_id", "run_number", "run_attempt", "actor", "actor_id", "workflow",
55 "workflow_ref", "workflow_sha", "job_workflow_ref", "job_workflow_sha", "head_ref", "base_ref", "event_name", "ref_type",
56 "ref_protected", "environment", "runner_environment"
57 ],
58 "id_token_signing_alg_values_supported": ["RS256"],
59 "scopes_supported": ["openid"],
60 })
61}
62
63// ── Keys ────────────────────────────────────────────────────────────────────
64
65/// A DER element: its tag, and its contents; and what follows it.
66fn der(input: &[u8]) -> Option<(u8, &[u8], &[u8])> {
67 let (&tag, rest) = input.split_first()?;
68 let (&first, rest) = rest.split_first()?;
69 let (length, rest) = if first < 0x80 {
70 (first as usize, rest)
71 } else {
72 let count = (first & 0x7f) as usize;
73 if count == 0 || count > 4 || rest.len() < count {
74 return None;
75 }
76 let length = rest[..count].iter().fold(0usize, |n, b| (n << 8) | *b as usize);
77 (length, &rest[count..])
78 };
79 if rest.len() < length {
80 return None;
81 }
82 Some((tag, &rest[..length], &rest[length..]))
83}
84
85/// An INTEGER's magnitude, without the sign byte DER may put in front.
86fn unsigned(bytes: &[u8]) -> &[u8] {
87 let mut bytes = bytes;
88 while bytes.len() > 1 && bytes[0] == 0 {
89 bytes = &bytes[1..];
90 }
91 bytes
92}
93
94/// The modulus and public exponent of an RSA private key: PKCS#1
95/// `RSAPrivateKey`, or PKCS#8 `PrivateKeyInfo` holding one.
96pub fn public_numbers(key: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
97 let (0x30, body, _) = der(key)? else { return None };
98 let (0x02, _version, rest) = der(body)? else { return None };
99 let rsa = match der(rest)? {
100 // PKCS#8: the algorithm, then the key in an OCTET STRING.
101 (0x30, _algorithm, after) => {
102 let (0x04, inner, _) = der(after)? else { return None };
103 let (0x30, rsa, _) = der(inner)? else { return None };
104 let (0x02, _version, rsa) = der(rsa)? else { return None };
105 rsa
106 }
107 // PKCS#1: the modulus is next.
108 (0x02, _, _) => rest,
109 _ => return None,
110 };
111 let (0x02, n, rsa) = der(rsa)? else { return None };
112 let (0x02, e, _) = der(rsa)? else { return None };
113 Some((unsigned(n).to_vec(), unsigned(e).to_vec()))
114}
115
116/// A DER length.
117fn der_length(length: usize) -> Vec<u8> {
118 if length < 0x80 {
119 return vec![length as u8];
120 }
121 let bytes: Vec<u8> = length.to_be_bytes().into_iter().skip_while(|byte| *byte == 0).collect();
122 let mut out = vec![0x80 | bytes.len() as u8];
123 out.extend(bytes);
124 out
125}
126
127/// Wraps a PKCS#1 key in PKCS#8, which is all WebCrypto imports.
128fn pkcs1_to_pkcs8(pkcs1: &[u8]) -> Vec<u8> {
129 const RSA_ALGORITHM: [u8; 15] = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00];
130 let mut octets = vec![0x04];
131 octets.extend(der_length(pkcs1.len()));
132 octets.extend_from_slice(pkcs1);
133 let mut body = vec![0x02, 0x01, 0x00];
134 body.extend_from_slice(&RSA_ALGORITHM);
135 body.extend(octets);
136 let mut out = vec![0x30];
137 out.extend(der_length(body.len()));
138 out.extend(body);
139 out
140}
141
142/// A signing key: its PKCS#8 DER, and its public half as a JWK.
143pub struct SigningKey {
144 pub pkcs8: Vec<u8>,
145 pub jwk: Value,
146}
147
148impl SigningKey {
149 /// From PEM, either form; line breaks pasted as `\n` are read too.
150 pub fn from_pem(pem: &str) -> std::result::Result<SigningKey, String> {
151 let pem = pem.replace("\\n", "\n");
152 let pkcs1 = pem.contains("BEGIN RSA PRIVATE KEY");
153 if !pkcs1 && !pem.contains("BEGIN PRIVATE KEY") {
154 return Err(format!("{KEY_SECRET} is not a PEM RSA private key."));
155 }
156 let body: String = pem.lines().filter(|l| !l.starts_with("-----")).flat_map(str::chars).filter(|c| !c.is_whitespace()).collect();
157 let der = STANDARD.decode(body).map_err(|_| format!("{KEY_SECRET} is not valid base64."))?;
158 let (n, e) = public_numbers(&der).ok_or_else(|| format!("{KEY_SECRET} is not an RSA key."))?;
159 let pkcs8 = if pkcs1 { pkcs1_to_pkcs8(&der) } else { der };
160 Ok(SigningKey { pkcs8, jwk: jwk(&n, &e) })
161 }
162
163 pub fn kid(&self) -> String {
164 self.jwk["kid"].as_str().unwrap_or_default().to_owned()
165 }
166}
167
168/// The public JWK of a key, its `kid` the RFC 7638 thumbprint.
169pub fn jwk(n: &[u8], e: &[u8]) -> Value {
170 let (n, e) = (URL_SAFE_NO_PAD.encode(n), URL_SAFE_NO_PAD.encode(e));
171 // RFC 7638: the required members, in lexicographic order, no spaces.
172 let canonical = format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#);
173 let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()));
174 json!({ "kty": "RSA", "alg": "RS256", "use": "sig", "kid": kid, "n": n, "e": e })
175}
176
177/// The keys configured: the current one first.
178pub fn keys(env: &Env) -> (Option<std::result::Result<SigningKey, String>>, Option<SigningKey>) {
179 let read = |name: &str| env.secret(name).ok().map(|s| s.to_string()).filter(|s| !s.trim().is_empty());
180 let current = read(KEY_SECRET).map(|pem| SigningKey::from_pem(&pem));
181 let previous = read(PREVIOUS_KEY_SECRET).and_then(|pem| SigningKey::from_pem(&pem).ok());
182 (current, previous)
183}
184
185/// Whether this installation can issue OIDC tokens.
186pub fn configured(env: &Env) -> bool {
187 matches!(keys(env).0, Some(Ok(_)))
188}
189
190pub fn jwks(current: Option<&SigningKey>, previous: Option<&SigningKey>) -> Value {
191 json!({ "keys": current.into_iter().chain(previous).map(|k| k.jwk.clone()).collect::<Vec<_>>() })
192}
193
194// ── Tokens ──────────────────────────────────────────────────────────────────
195
196/// The token's claims: what the actions service said about the job, and
197/// who issued it, for whom and when.
198pub fn full_claims(mut claims: Value, issuer: &str, audience: &str, jti: &str, now: u64) -> Value {
199 claims["iss"] = json!(issuer);
200 claims["aud"] = json!(audience);
201 claims["jti"] = json!(jti);
202 claims["iat"] = json!(now);
203 claims["nbf"] = json!(now.saturating_sub(60));
204 claims["exp"] = json!(now + LIFETIME_SECONDS);
205 claims
206}
207
208/// The header and claims, base64url-encoded and joined: what is signed.
209pub fn signing_input(kid: &str, claims: &Value) -> String {
210 let header = json!({ "typ": "JWT", "alg": "RS256", "kid": kid, "x5t": kid });
211 format!("{}.{}", URL_SAFE_NO_PAD.encode(header.to_string()), URL_SAFE_NO_PAD.encode(claims.to_string()))
212}
213
214/// RSASSA-PKCS1-v1_5 with SHA-256, by WebCrypto.
215async fn sign_rs256(pkcs8: &[u8], data: &[u8]) -> Result<Vec<u8>> {
216 let subtle = js::get(&js::get(&js_sys::global(), "crypto"), "subtle");
217 let algorithm = js::to_js(&json!({ "name": "RSASSA-PKCS1-v1_5", "hash": "SHA-256" }))?;
218 let usages = js::to_js(&json!(["sign"]))?;
219 let key = js::call(&subtle, "importKey", &["pkcs8".into(), Uint8Array::from(pkcs8).into(), algorithm.clone(), false.into(), usages])
220 .await
221 .map_err(|thrown| Error::RustError(format!("{KEY_SECRET} could not be used: {thrown}")))?;
222 let signature = js::call(&subtle, "sign", &[algorithm, key, Uint8Array::from(data).into()]).await?;
223 Ok(Uint8Array::new(&signature).to_vec())
224}
225
226fn error(status: u16, message: &str) -> Result<Response> {
227 Ok(crate::reply(&json!({ "error": { "message": message } }))?.with_status(status))
228}
229
230/// `/actions/oidc/…`: discovery, keys, and a job's token.
231pub async fn handle(request: &Request, env: &Env, services: &Services, path: &str) -> Result<Response> {
232 let api = services.addresses.api.clone();
233 let (current, previous) = keys(env);
234 let current = match current {
235 Some(Ok(key)) => key,
236 Some(Err(problem)) => {
237 worker::console_error!("oidc: {problem}");
238 return error(503, "OIDC tokens are not set up on this installation.");
239 }
240 None => return error(404, "OIDC tokens are not set up on this installation."),
241 };
242 let cached = |value: &Value| -> Result<Response> {
243 let mut response = Response::from_json(value)?;
244 response.headers_mut().set("cache-control", "public, max-age=300")?;
245 Ok(response)
246 };
247 match path {
248 "/actions/oidc/.well-known/openid-configuration" => cached(&discovery(&api)),
249 "/actions/oidc/.well-known/jwks" => cached(&jwks(Some(&current), previous.as_ref())),
250 "/actions/oidc/token" => {
251 let token = crate::toolkit::bearer(request);
252 let Some(job) = crate::toolkit::runtime_job(&token) else {
253 return error(401, "Send the job's ACTIONS_ID_TOKEN_REQUEST_TOKEN as a bearer token.");
254 };
255 let claims: Outcome<Value> = g1t_kit::call(&services.actions, "oidc_claims", &RuntimeAuthArgs { job, token }).await?;
256 let claims = match claims {
257 Outcome::Ok(claims) => claims,
258 Outcome::Fail(refused) => {
259 let status = match refused.code {
260 FailureCode::Unauthenticated => 401,
261 FailureCode::Forbidden => 403,
262 _ => 404,
263 };
264 return error(status, &refused.message);
265 }
266 };
267 let url = request.url()?;
268 let owner = claims["repository_owner"].as_str().unwrap_or_default().to_owned();
269 let audience = url
270 .query_pairs()
271 .find(|(k, _)| k == "audience")
272 .map(|(_, v)| v.into_owned())
273 .filter(|a| !a.trim().is_empty())
274 // GitHub's default: the owner's address.
275 .unwrap_or_else(|| format!("{}/{owner}", services.addresses.site));
276 let now = g1t_kit::now_ms() / 1000;
277 let jti = g1t_contracts::new_id("oidc", g1t_kit::now_ms());
278 let claims = full_claims(claims, &issuer(&api), &audience, &jti, now);
279 let input = signing_input(&current.kid(), &claims);
280 let signature = sign_rs256(&current.pkcs8, input.as_bytes()).await?;
281 let value = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature));
282 Response::from_json(&json!({ "count": value.len(), "value": value }))
283 }
284 _ => error(404, "No such endpoint."),
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use std::process::Command;
292
293 #[test]
294 fn discovery_names_the_issuer_and_its_keys() {
295 let doc = discovery("https://api.g1t.sh");
296 assert_eq!(doc["issuer"], "https://api.g1t.sh/actions/oidc");
297 assert_eq!(doc["jwks_uri"], "https://api.g1t.sh/actions/oidc/.well-known/jwks");
298 assert_eq!(doc["id_token_signing_alg_values_supported"], json!(["RS256"]));
299 assert!(doc["claims_supported"].as_array().unwrap().contains(&json!("job_workflow_ref")));
300 }
301
302 #[test]
303 fn a_thumbprint_is_rfc_7638s() {
304 // RFC 7638, section 3.1: the example key and its thumbprint.
305 let n = URL_SAFE_NO_PAD
306 .decode("0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw")
307 .unwrap();
308 let key = jwk(&n, &[1, 0, 1]);
309 assert_eq!(key["e"], "AQAB");
310 assert_eq!(key["kid"], "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs");
311 }
312
313 #[test]
314 fn claims_get_who_issued_them_and_a_short_life() {
315 let claims = full_claims(json!({ "sub": "repo:acme/web:ref:refs/heads/main" }), "https://api.g1t.sh/actions/oidc", "sts.amazonaws.com", "oidc_1", 1_700_000_000);
316 assert_eq!(claims["iss"], "https://api.g1t.sh/actions/oidc");
317 assert_eq!(claims["aud"], "sts.amazonaws.com");
318 assert_eq!(claims["exp"].as_u64().unwrap() - claims["iat"].as_u64().unwrap(), LIFETIME_SECONDS);
319 assert!(claims["nbf"].as_u64().unwrap() <= claims["iat"].as_u64().unwrap());
320 let input = signing_input("kid1", &claims);
321 let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(input.split('.').next().unwrap()).unwrap()).unwrap();
322 assert_eq!((header["alg"].as_str(), header["kid"].as_str()), (Some("RS256"), Some("kid1")));
323 }
324
325 #[test]
326 fn a_key_that_is_not_one_is_refused() {
327 assert!(SigningKey::from_pem("hello").is_err());
328 let pem = format!("{}\nAAAA\n{}", concat!("-----BEGIN ", "PRIVATE KEY-----"), concat!("-----END ", "PRIVATE KEY-----"));
329 assert!(SigningKey::from_pem(&pem).is_err());
330 }
331
332 fn openssl(args: &[&str]) -> Option<std::process::Output> {
333 Command::new("openssl").args(args).output().ok().filter(|o| o.status.success())
334 }
335
336 /// With openssl on the machine: a key made here, read in both PEM
337 /// forms, gives the modulus openssl gives, and a token signed with it
338 /// (by openssl, as WebCrypto is not here) verifies against the public
339 /// key built from the JWKS.
340 #[test]
341 fn a_real_key_signs_tokens_its_jwks_verifies() {
342 let dir = std::env::temp_dir().join(format!("g1t-oidc-test-{}", std::process::id()));
343 let _ = std::fs::create_dir_all(&dir);
344 let path = |name: &str| dir.join(name).display().to_string();
345 if openssl(&["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", &path("key.pem")]).is_none() {
346 eprintln!("openssl is not here; skipped");
347 return;
348 }
349 let pem = std::fs::read_to_string(path("key.pem")).unwrap();
350 let key = SigningKey::from_pem(&pem).unwrap();
351 // The modulus is openssl's.
352 let modulus = openssl(&["rsa", "-in", &path("key.pem"), "-noout", "-modulus"]).unwrap();
353 let modulus = String::from_utf8_lossy(&modulus.stdout).trim().trim_start_matches("Modulus=").to_lowercase();
354 let n = URL_SAFE_NO_PAD.decode(key.jwk["n"].as_str().unwrap()).unwrap();
355 assert_eq!(n.iter().map(|b| format!("{b:02x}")).collect::<String>(), modulus);
356 assert_eq!(key.jwk["e"], "AQAB");
357 // The traditional form reads to the same key.
358 if openssl(&["rsa", "-in", &path("key.pem"), "-traditional", "-out", &path("key1.pem")]).is_some() {
359 let pkcs1 = std::fs::read_to_string(path("key1.pem")).unwrap();
360 if pkcs1.contains("BEGIN RSA PRIVATE KEY") {
361 let again = SigningKey::from_pem(&pkcs1).unwrap();
362 assert_eq!(again.kid(), key.kid());
363 assert_eq!(again.pkcs8, key.pkcs8, "PKCS#1 is wrapped as openssl writes PKCS#8");
364 }
365 }
366 // Sign the token's input with openssl, verify with the JWKS's key.
367 let claims = full_claims(json!({ "sub": "repo:acme/web:environment:prod" }), "https://api.g1t.sh/actions/oidc", "sts.amazonaws.com", "j", 1_700_000_000);
368 let input = signing_input(&key.kid(), &claims);
369 std::fs::write(path("input"), &input).unwrap();
370 openssl(&["dgst", "-sha256", "-sign", &path("key.pem"), "-out", &path("sig"), &path("input")]).unwrap();
371 // The public key from n and e alone: RSAPublicKey DER.
372 let integer = |bytes: &[u8]| {
373 let mut value = bytes.to_vec();
374 if value[0] & 0x80 != 0 {
375 value.insert(0, 0);
376 }
377 let mut out = vec![0x02];
378 out.extend(der_length(value.len()));
379 out.extend(value);
380 out
381 };
382 let mut body = integer(&n);
383 body.extend(integer(&URL_SAFE_NO_PAD.decode(key.jwk["e"].as_str().unwrap()).unwrap()));
384 let mut public = vec![0x30];
385 public.extend(der_length(body.len()));
386 public.extend(body);
387 std::fs::write(path("public.der"), &public).unwrap();
388 let checked = openssl(&["rsa", "-RSAPublicKey_in", "-inform", "DER", "-in", &path("public.der"), "-pubout", "-out", &path("public.pem")]);
389 assert!(checked.is_some(), "openssl reads the public key built from the JWKS");
390 let verified = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]);
391 assert!(verified.is_some(), "the JWKS key verifies the token's signature");
392 // And not a token whose claims were changed.
393 std::fs::write(path("input"), format!("{input}A")).unwrap();
394 let forged = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]);
395 assert!(forged.is_none());
396 let _ = std::fs::remove_dir_all(&dir);
397 }
398}