Skip to content
417 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 audience a job asked for with `audience=` (as `core.getIDToken`
209/// appends it, percent-encoded), or, when it asked for none, the owner's
210/// address on the site, which is the default trust policies expect.
211pub fn audience(url: &worker::Url, site: &str, owner: &str) -> String {
212 url.query_pairs()
213 .find(|(k, _)| k == "audience")
214 .map(|(_, v)| v.into_owned())
215 .filter(|a| !a.trim().is_empty())
216 .unwrap_or_else(|| format!("{site}/{owner}"))
217}
218
219/// The header and claims, base64url-encoded and joined: what is signed.
220pub fn signing_input(kid: &str, claims: &Value) -> String {
221 let header = json!({ "typ": "JWT", "alg": "RS256", "kid": kid, "x5t": kid });
222 format!("{}.{}", URL_SAFE_NO_PAD.encode(header.to_string()), URL_SAFE_NO_PAD.encode(claims.to_string()))
223}
224
225/// RSASSA-PKCS1-v1_5 with SHA-256, by WebCrypto.
226async fn sign_rs256(pkcs8: &[u8], data: &[u8]) -> Result<Vec<u8>> {
227 let subtle = js::get(&js::get(&js_sys::global(), "crypto"), "subtle");
228 let algorithm = js::to_js(&json!({ "name": "RSASSA-PKCS1-v1_5", "hash": "SHA-256" }))?;
229 let usages = js::to_js(&json!(["sign"]))?;
230 let key = js::call(&subtle, "importKey", &["pkcs8".into(), Uint8Array::from(pkcs8).into(), algorithm.clone(), false.into(), usages])
231 .await
232 .map_err(|thrown| Error::RustError(format!("{KEY_SECRET} could not be used: {thrown}")))?;
233 let signature = js::call(&subtle, "sign", &[algorithm, key, Uint8Array::from(data).into()]).await?;
234 Ok(Uint8Array::new(&signature).to_vec())
235}
236
237fn error(status: u16, message: &str) -> Result<Response> {
238 Ok(crate::reply(&json!({ "error": { "message": message } }))?.with_status(status))
239}
240
241/// `/actions/oidc/…`: discovery, keys, and a job's token.
242pub async fn handle(request: &Request, env: &Env, services: &Services, path: &str) -> Result<Response> {
243 let api = services.addresses.api.clone();
244 let (current, previous) = keys(env);
245 let current = match current {
246 Some(Ok(key)) => key,
247 Some(Err(problem)) => {
248 worker::console_error!("oidc: {problem}");
249 return error(503, "OIDC tokens are not set up on this installation.");
250 }
251 None => return error(404, "OIDC tokens are not set up on this installation."),
252 };
253 let cached = |value: &Value| -> Result<Response> {
254 let mut response = Response::from_json(value)?;
255 response.headers_mut().set("cache-control", "public, max-age=300")?;
256 Ok(response)
257 };
258 match path {
259 "/actions/oidc/.well-known/openid-configuration" => cached(&discovery(&api)),
260 "/actions/oidc/.well-known/jwks" => cached(&jwks(Some(&current), previous.as_ref())),
261 "/actions/oidc/token" => {
262 let token = crate::toolkit::bearer(request);
263 let Some(job) = crate::toolkit::runtime_job(&token) else {
264 return error(401, "Send the job's ACTIONS_ID_TOKEN_REQUEST_TOKEN as a bearer token.");
265 };
266 let claims: Outcome<Value> = g1t_kit::call(&services.actions, "oidc_claims", &RuntimeAuthArgs { job, token }).await?;
267 let claims = match claims {
268 Outcome::Ok(claims) => claims,
269 Outcome::Fail(refused) => {
270 let status = match refused.code {
271 FailureCode::Unauthenticated => 401,
272 FailureCode::Forbidden => 403,
273 _ => 404,
274 };
275 return error(status, &refused.message);
276 }
277 };
278 let owner = claims["repository_owner"].as_str().unwrap_or_default();
279 let audience = audience(&request.url()?, &services.addresses.site, owner);
280 let now = g1t_kit::now_ms() / 1000;
281 let jti = g1t_contracts::new_id("oidc", g1t_kit::now_ms());
282 let claims = full_claims(claims, &issuer(&api), &audience, &jti, now);
283 let input = signing_input(&current.kid(), &claims);
284 let signature = sign_rs256(&current.pkcs8, input.as_bytes()).await?;
285 let value = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature));
286 Response::from_json(&json!({ "count": value.len(), "value": value }))
287 }
288 _ => error(404, "No such endpoint."),
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use std::process::Command;
296
297 #[test]
298 fn discovery_names_the_issuer_and_its_keys() {
299 let doc = discovery("https://api.g1t.sh");
300 assert_eq!(doc["issuer"], "https://api.g1t.sh/actions/oidc");
301 assert_eq!(doc["jwks_uri"], "https://api.g1t.sh/actions/oidc/.well-known/jwks");
302 assert_eq!(doc["id_token_signing_alg_values_supported"], json!(["RS256"]));
303 assert!(doc["claims_supported"].as_array().unwrap().contains(&json!("job_workflow_ref")));
304 }
305
306 #[test]
307 fn a_thumbprint_is_rfc_7638s() {
308 // RFC 7638, section 3.1: the example key and its thumbprint.
309 let n = URL_SAFE_NO_PAD
310 .decode("0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw")
311 .unwrap();
312 let key = jwk(&n, &[1, 0, 1]);
313 assert_eq!(key["e"], "AQAB");
314 assert_eq!(key["kid"], "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs");
315 }
316
317 #[test]
318 fn claims_get_who_issued_them_and_a_short_life() {
319 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);
320 assert_eq!(claims["iss"], "https://api.g1t.sh/actions/oidc");
321 assert_eq!(claims["aud"], "sts.amazonaws.com");
322 assert_eq!(claims["exp"].as_u64().unwrap() - claims["iat"].as_u64().unwrap(), LIFETIME_SECONDS);
323 assert!(claims["nbf"].as_u64().unwrap() <= claims["iat"].as_u64().unwrap());
324 let input = signing_input("kid1", &claims);
325 let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(input.split('.').next().unwrap()).unwrap()).unwrap();
326 assert_eq!((header["alg"].as_str(), header["kid"].as_str()), (Some("RS256"), Some("kid1")));
327 }
328
329 #[test]
330 fn the_audience_is_the_one_asked_for_or_the_owners_address() {
331 let url = |q: &str| worker::Url::parse(&format!("https://api.g1t.sh/actions/oidc/token?api-version=2.0{q}")).unwrap();
332 assert_eq!(audience(&url("&audience=sts.amazonaws.com"), "https://g1t.sh", "acme"), "sts.amazonaws.com");
333 // Percent-encoded, as the toolkit sends it.
334 assert_eq!(audience(&url("&audience=api%3A%2F%2FAzureADTokenExchange"), "https://g1t.sh", "acme"), "api://AzureADTokenExchange");
335 assert_eq!(
336 audience(&url("&audience=%2F%2Fiam.googleapis.com%2Fprojects%2F1%2Fproviders%2Fg1t"), "https://g1t.sh", "acme"),
337 "//iam.googleapis.com/projects/1/providers/g1t"
338 );
339 // None, or an empty one: the owner's address.
340 assert_eq!(audience(&url(""), "https://g1t.sh", "acme"), "https://g1t.sh/acme");
341 assert_eq!(audience(&url("&audience="), "https://g1t.sh", "acme"), "https://g1t.sh/acme");
342 }
343
344 #[test]
345 fn a_key_that_is_not_one_is_refused() {
346 assert!(SigningKey::from_pem("hello").is_err());
347 let pem = format!("{}\nAAAA\n{}", concat!("-----BEGIN ", "PRIVATE KEY-----"), concat!("-----END ", "PRIVATE KEY-----"));
348 assert!(SigningKey::from_pem(&pem).is_err());
349 }
350
351 fn openssl(args: &[&str]) -> Option<std::process::Output> {
352 Command::new("openssl").args(args).output().ok().filter(|o| o.status.success())
353 }
354
355 /// With openssl on the machine: a key made here, read in both PEM
356 /// forms, gives the modulus openssl gives, and a token signed with it
357 /// (by openssl, as WebCrypto is not here) verifies against the public
358 /// key built from the JWKS.
359 #[test]
360 fn a_real_key_signs_tokens_its_jwks_verifies() {
361 let dir = std::env::temp_dir().join(format!("g1t-oidc-test-{}", std::process::id()));
362 let _ = std::fs::create_dir_all(&dir);
363 let path = |name: &str| dir.join(name).display().to_string();
364 if openssl(&["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", &path("key.pem")]).is_none() {
365 eprintln!("openssl is not here; skipped");
366 return;
367 }
368 let pem = std::fs::read_to_string(path("key.pem")).unwrap();
369 let key = SigningKey::from_pem(&pem).unwrap();
370 // The modulus is openssl's.
371 let modulus = openssl(&["rsa", "-in", &path("key.pem"), "-noout", "-modulus"]).unwrap();
372 let modulus = String::from_utf8_lossy(&modulus.stdout).trim().trim_start_matches("Modulus=").to_lowercase();
373 let n = URL_SAFE_NO_PAD.decode(key.jwk["n"].as_str().unwrap()).unwrap();
374 assert_eq!(n.iter().map(|b| format!("{b:02x}")).collect::<String>(), modulus);
375 assert_eq!(key.jwk["e"], "AQAB");
376 // The traditional form reads to the same key.
377 if openssl(&["rsa", "-in", &path("key.pem"), "-traditional", "-out", &path("key1.pem")]).is_some() {
378 let pkcs1 = std::fs::read_to_string(path("key1.pem")).unwrap();
379 if pkcs1.contains("BEGIN RSA PRIVATE KEY") {
380 let again = SigningKey::from_pem(&pkcs1).unwrap();
381 assert_eq!(again.kid(), key.kid());
382 assert_eq!(again.pkcs8, key.pkcs8, "PKCS#1 is wrapped as openssl writes PKCS#8");
383 }
384 }
385 // Sign the token's input with openssl, verify with the JWKS's key.
386 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);
387 let input = signing_input(&key.kid(), &claims);
388 std::fs::write(path("input"), &input).unwrap();
389 openssl(&["dgst", "-sha256", "-sign", &path("key.pem"), "-out", &path("sig"), &path("input")]).unwrap();
390 // The public key from n and e alone: RSAPublicKey DER.
391 let integer = |bytes: &[u8]| {
392 let mut value = bytes.to_vec();
393 if value[0] & 0x80 != 0 {
394 value.insert(0, 0);
395 }
396 let mut out = vec![0x02];
397 out.extend(der_length(value.len()));
398 out.extend(value);
399 out
400 };
401 let mut body = integer(&n);
402 body.extend(integer(&URL_SAFE_NO_PAD.decode(key.jwk["e"].as_str().unwrap()).unwrap()));
403 let mut public = vec![0x30];
404 public.extend(der_length(body.len()));
405 public.extend(body);
406 std::fs::write(path("public.der"), &public).unwrap();
407 let checked = openssl(&["rsa", "-RSAPublicKey_in", "-inform", "DER", "-in", &path("public.der"), "-pubout", "-out", &path("public.pem")]);
408 assert!(checked.is_some(), "openssl reads the public key built from the JWKS");
409 let verified = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]);
410 assert!(verified.is_some(), "the JWKS key verifies the token's signature");
411 // And not a token whose claims were changed.
412 std::fs::write(path("input"), format!("{input}A")).unwrap();
413 let forged = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]);
414 assert!(forged.is_none());
415 let _ = std::fs::remove_dir_all(&dir);
416 }
417}