Skip to content
642 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R21//! The toolkit's credentials: a job's runtime token, the signed tokens of
2//! the blob URLs the toolkit uploads to and downloads from, and the claims
3//! of a job's OIDC token.
4//!
5//! - The runtime token (`ACTIONS_RUNTIME_TOKEN`) is a JSON Web Token, as
6//! the toolkit expects: `@actions/artifact` reads the run and job from
7//! its `scp` claim. It is signed (HS256) with the hash of the job's own
8//! token as the key, so it is checked against the job's row without a
9//! secret of its own, stops working when the job ends, and never lets
10//! its holder read the job's spec or report for it.
11//! - A blob token is a payload and its HMAC under a key derived from
12//! `ACTIONS_KEY`: which entry, which R2 upload (for an upload), and until
13//! when. The API serves `/actions/toolkit/blobs/{token}` with it.
14//! - OIDC claims follow GitHub's, so cloud providers' trust policies read
15//! them the same way. The API adds `iss`, `aud`, `jti` and the times,
16//! and signs.
17
18use base64::Engine;
19use base64::engine::general_purpose::URL_SAFE_NO_PAD;
20use g1t_actions::events::RunInfo;
21use g1t_contracts::actions::{BlobArgs, BlobGrant, BlobPart, BlobSignArgs, RuntimeAuthArgs, RuntimeJob};
22use g1t_contracts::time::rfc3339;
23use g1t_contracts::{FailureCode, Outcome};
24use g1t_kit::now_ms;
25use g1t_secrets::{hmac_sha256_hex, same, sha256_hex};
26use serde::Deserialize;
27use serde_json::{Value, json};
28use worker::Result;
29
30use crate::plan::{JobRow, RunRow};
31use crate::{Actions, check, fail};
32
33/// How long a blob token for an upload is good: as long as an unfinished
34/// upload is kept (cache.rs).
35pub(crate) const UPLOAD_SECONDS: u64 = 6 * 60 * 60;
36/// How long a blob token for a download is good: long enough to start one.
37pub(crate) const DOWNLOAD_SECONDS: u64 = 60 * 60;
38/// How long a download link the API redirects a person to is good.
39pub(crate) const LINK_SECONDS: u64 = 10 * 60;
40
41fn encode(bytes: &[u8]) -> String {
42 URL_SAFE_NO_PAD.encode(bytes)
43}
44
45fn decode_json(part: &str) -> Option<Value> {
46 serde_json::from_slice(&URL_SAFE_NO_PAD.decode(part).ok()?).ok()
47}
48
49/// HMAC-SHA256 of `body` under `key`, base64url.
50fn mac(key: &str, body: &str) -> String {
51 encode(&hex::decode(hmac_sha256_hex(key, body)).unwrap_or_default())
52}
53
54// ── The runtime token ───────────────────────────────────────────────────────
55
56/// The scopes the toolkit reads: `Actions.Results:{run}:{job}` names the
57/// run and job to `@actions/artifact`.
58pub(crate) fn scopes(run: &str, job: &str) -> String {
59 format!("Actions.GenericRead:00000000-0000-0000-0000-000000000000 Actions.UploadArtifacts:{run}:{job} Actions.Results:{run}:{job}")
60}
61
62/// A job's runtime token, good for `ttl` seconds from `now` (seconds).
63/// `token_hash` is the hash of the job's own token, as its row keeps it.
64pub(crate) fn runtime_token(job: &str, run: &str, token_hash: &str, now: u64, ttl: u64) -> String {
65 let header = encode(br#"{"typ":"JWT","alg":"HS256"}"#);
66 let claims = json!({
67 "iss": "g1t",
68 "sub": job,
69 "job": job,
70 "run": run,
71 "scp": scopes(run, job),
72 "iat": now,
73 "nbf": now.saturating_sub(60),
74 "exp": now + ttl,
75 });
76 let claims = encode(claims.to_string().as_bytes());
77 let signed = format!("{header}.{claims}");
78 let signature = mac(token_hash, &signed);
79 format!("{signed}.{signature}")
80}
81
82/// The job a runtime token says it is, unchecked: the API reads it to know
83/// which job to ask about.
84pub fn runtime_job(token: &str) -> Option<String> {
85 let claims = decode_json(token.split('.').nth(1)?)?;
86 claims["job"].as_str().map(str::to_owned)
87}
88
89/// Whether `token` is a good runtime token for `job`, whose own token
90/// hashes to `token_hash`, at `now` (seconds).
91pub(crate) fn runtime_valid(token: &str, job: &str, token_hash: &str, now: u64) -> bool {
92 let mut parts = token.split('.');
93 let (Some(header), Some(claims), Some(signature), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
94 return false;
95 };
96 if !same(&mac(token_hash, &format!("{header}.{claims}")), signature) {
97 return false;
98 }
99 let Some(claims) = decode_json(claims) else { return false };
100 claims["job"].as_str() == Some(job) && claims["exp"].as_u64().is_some_and(|exp| exp > now)
101}
102
103/// Whether `token` looks like a runtime token rather than a job's own
104/// (which is hex).
105pub(crate) fn is_runtime(token: &str) -> bool {
106 token.matches('.').count() == 2
107}
108
109// ── Blob tokens ─────────────────────────────────────────────────────────────
110
111#[derive(Debug, PartialEq, serde::Serialize, Deserialize)]
112pub(crate) struct BlobClaims {
113 /// `cache` or `artifact`.
114 pub k: String,
115 /// The entry's id.
116 pub i: String,
117 /// Its object in R2.
118 pub o: String,
119 /// The R2 upload, for an upload.
120 #[serde(default)]
121 pub u: Option<String>,
122 /// Good until, seconds since the epoch.
123 pub e: u64,
124}
125
126/// The key blob tokens are signed with, from the service's own key.
127pub(crate) fn blob_key(actions_key: &str) -> String {
128 hmac_sha256_hex(actions_key, "g1t actions blob tokens v1")
129}
130
131pub(crate) fn sign_blob(key: &str, claims: &BlobClaims) -> String {
132 let payload = encode(serde_json::to_string(claims).unwrap_or_default().as_bytes());
133 let signature = mac(key, &payload);
134 format!("{payload}.{signature}")
135}
136
137/// What a blob token grants, if it is signed with `key` and good at `now`.
138pub(crate) fn open_blob(key: &str, token: &str, now: u64) -> Option<BlobClaims> {
139 let (payload, signature) = token.split_once('.')?;
140 if !same(&mac(key, payload), signature) {
141 return None;
142 }
143 let claims: BlobClaims = serde_json::from_value(decode_json(payload)?).ok()?;
144 (claims.e > now).then_some(claims)
145}
146
147// ── OIDC ────────────────────────────────────────────────────────────────────
148
149/// Whether a job may have an OIDC token: when its permissions, or its
150/// workflow's when it has none of its own, give `id-token: write`
151/// (`write-all` included). Nothing given gives no OIDC token, as GitHub's
152/// default token permissions do not include it.
153///
154/// This reads only the `id-token` key. When `permissions:` as a whole is
155/// read elsewhere, this is the one place to plug that in.
156pub(crate) fn id_token_permitted(workflow: &Value, job: &Value) -> bool {
157 let permissions = match job.get("permissions") {
158 Some(own) => own,
159 None => match workflow.get("permissions") {
160 Some(theirs) => theirs,
161 None => return false,
162 },
163 };
164 match permissions {
165 Value::String(all) => all.trim() == "write-all",
166 Value::Object(each) => each.get("id-token").and_then(Value::as_str).is_some_and(|level| level.trim() == "write"),
167 _ => false,
168 }
169}
170
171/// A job's `environment:` name, when it names one plainly.
172pub(crate) fn environment_name(job: &Value) -> Option<String> {
173 match job.get("environment") {
174 Some(Value::String(name)) if !name.contains("${{") && !name.trim().is_empty() => Some(name.trim().to_owned()),
175 Some(Value::Object(env)) => env.get("name").and_then(Value::as_str).filter(|n| !n.contains("${{") && !n.trim().is_empty()).map(|n| n.trim().to_owned()),
176 _ => None,
177 }
178}
179
180/// GitHub's subject: by environment, else for a pull request, else by ref.
181pub fn subject(repository: &str, environment: Option<&str>, event: &str, git_ref: &str) -> String {
182 if let Some(environment) = environment {
183 return format!("repo:{repository}:environment:{environment}");
184 }
185 if event == "pull_request" || event == "pull_request_target" {
186 return format!("repo:{repository}:pull_request");
187 }
188 format!("repo:{repository}:ref:{git_ref}")
189}
190
191/// What an OIDC token says about a job, besides who issued it, for whom
192/// and when.
193pub(crate) struct ClaimFacts<'a> {
194 pub info: &'a RunInfo,
195 pub environment: Option<&'a str>,
196 /// The workflow that defines the job: a called workflow's own path.
197 pub job_workflow_path: &'a str,
198 pub private: bool,
199 pub owner_id: &'a str,
200 pub self_hosted: bool,
201}
202
203pub(crate) fn claims(facts: &ClaimFacts) -> Value {
204 let info = facts.info;
205 let owner = info.repository.split('/').next().unwrap_or_default();
206 let workflow_ref = format!("{}/{}@{}", info.repository, info.workflow_path, info.git_ref);
207 let job_workflow_ref = format!("{}/{}@{}", info.repository, facts.job_workflow_path, info.git_ref);
208 let mut claims = json!({
209 "sub": subject(&info.repository, facts.environment, &info.event_name, &info.git_ref),
210 "ref": info.git_ref,
211 "sha": info.sha,
212 "repository": info.repository,
213 "repository_owner": owner,
214 "repository_owner_id": facts.owner_id,
215 "repository_id": info.repository_id,
216 "repository_visibility": if facts.private { "private" } else { "public" },
217 "run_id": info.run_id,
218 "run_number": info.run_number.to_string(),
219 "run_attempt": info.run_attempt.to_string(),
220 "actor": info.actor,
221 "actor_id": info.actor_id,
222 "workflow": info.workflow,
223 "workflow_ref": workflow_ref,
224 "workflow_sha": info.sha,
225 "job_workflow_ref": job_workflow_ref,
226 "job_workflow_sha": info.sha,
227 "head_ref": info.head_ref.clone().unwrap_or_default(),
228 "base_ref": info.base_ref.clone().unwrap_or_default(),
229 "event_name": info.event_name,
230 "ref_type": info.ref_type(),
231 "ref_protected": (info.ref_name() == info.default_branch).to_string(),
232 "runner_environment": if facts.self_hosted { "self-hosted" } else { "github-hosted" },
233 });
234 if let Some(environment) = facts.environment {
235 claims["environment"] = json!(environment);
236 }
237 claims
238}
239
240impl Actions {
241 /// The running job a runtime token is for.
242 pub(crate) async fn job_for_runtime(&self, job: &str, token: &str) -> Result<Outcome<JobRow>> {
243 let row = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job.into()])?.first::<JobRow>(None).await?;
244 Ok(match row {
245 Some(row)
246 if row.status == "in_progress"
247 && row.token_hash.as_deref().is_some_and(|hash| runtime_valid(token, job, hash, now_ms() / 1000)) =>
248 {
249 Outcome::Ok(row)
250 }
251 _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."),
252 })
253 }
254
255 /// The running job a sandbox's credential is for: its job's own token,
256 /// or its runtime token. Only for the cache and artifacts: a runtime
257 /// token never reads a job's spec or reports for it.
258 pub(crate) async fn job_for_credential(&self, job: &str, token: &str) -> Result<Outcome<JobRow>> {
259 if is_runtime(token) {
260 return self.job_for_runtime(job, token).await;
261 }
262 let row = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job.into()])?.first::<JobRow>(None).await?;
263 Ok(match row {
264 Some(row) if row.status == "in_progress" && row.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(token))) => Outcome::Ok(row),
265 _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."),
266 })
267 }
268
269 /// `runtime_auth`.
270 pub async fn runtime_auth(&self, a: RuntimeAuthArgs) -> Result<Outcome<RuntimeJob>> {
271 let job = check!(self.job_for_runtime(&a.job, &a.token).await?);
272 let Some(run) = self.run_row(&job.run_id).await? else {
273 return Ok(fail(FailureCode::NotFound, "No such run."));
274 };
275 Ok(Outcome::Ok(RuntimeJob { job: job.id, run: job.run_id, repo_id: job.repo_id, namespace: job.namespace, repository: run.repo }))
276 }
277
278 /// `oidc_claims`.
279 pub async fn oidc_claims(&self, a: RuntimeAuthArgs) -> Result<Outcome<Value>> {
280 let job = check!(self.job_for_runtime(&a.job, &a.token).await?);
281 let Some(run) = self.run_row(&job.run_id).await? else {
282 return Ok(fail(FailureCode::NotFound, "No such run."));
283 };
284 let Some(permitted) = self.oidc_permitted(&run, &job) else {
285 return Ok(fail(FailureCode::Forbidden, "The workflow no longer reads."));
286 };
287 if !permitted.0 {
288 return Ok(fail(
289 FailureCode::Forbidden,
290 "This job has no OIDC token: give it `permissions: id-token: write` (a run of a pull request from outside the repository never has one).",
291 ));
292 }
293 let (repo, _) = match self.repo_by_id(&run.repo_id).await? {
294 Some(found) => found,
295 None => return Ok(fail(FailureCode::NotFound, "There is no such repository.")),
296 };
297 let info = run.info();
298 let facts = ClaimFacts {
299 info: &info,
300 environment: permitted.1.as_deref(),
301 job_workflow_path: &permitted.2,
302 private: repo.is_private,
303 owner_id: &repo.owner_id,
304 self_hosted: job.runner_id.is_some(),
305 };
306 Ok(Outcome::Ok(claims(&facts)))
307 }
308
309 /// Whether a job may have an OIDC token, its environment, and the path
310 /// of the workflow that defines it. A called workflow's job needs both
311 /// its own permissions and its caller's to allow it, as on GitHub. A
312 /// run that is not trusted (a pull request from outside) never may.
313 /// `None` when the workflow no longer reads.
314 fn oidc_permitted(&self, run: &RunRow, job: &JobRow) -> Option<(bool, Option<String>, String)> {
315 let caller = g1t_actions::workflow::parse(&run.source).ok()?;
316 let trusted = run.trusted != 0;
317 match job.callee() {
318 Some((called, spec, call)) => {
319 let parent = call["parent"].as_str().unwrap_or_default();
320 let caller_allows = caller.jobs.iter().find(|j| j.id == parent).is_none_or(|j| id_token_permitted(&caller.raw, &j.raw));
321 let path = call["path"].as_str().unwrap_or(&run.path).to_owned();
322 Some((trusted && caller_allows && id_token_permitted(&called.raw, &spec.raw), environment_name(&spec.raw), path))
323 }
324 None => {
325 let spec = caller.jobs.iter().find(|j| j.id == job.key)?;
326 Some((trusted && id_token_permitted(&caller.raw, &spec.raw), environment_name(&spec.raw), run.path.clone()))
327 }
328 }
329 }
330
331 /// Whether a job may have an OIDC token, for its spec.
332 pub(crate) fn oidc_allowed(&self, run: &RunRow, job: &JobRow) -> bool {
333 self.oidc_permitted(run, job).is_some_and(|p| p.0)
334 }
335
336 fn blob_signing_key(&self) -> Option<String> {
337 self.blob_key.clone()
338 }
339
340 /// `blob_sign`.
341 pub async fn blob_sign(&self, a: BlobSignArgs) -> Result<Outcome<String>> {
342 let job = check!(self.job_for_credential(&a.job, &a.token).await?);
343 let Some(key) = self.blob_signing_key() else {
344 return Ok(fail(FailureCode::Invalid, "The toolkit's storage is not set up here: the actions service has no ACTIONS_KEY."));
345 };
346 let object = match a.kind.as_str() {
347 "cache" => {
348 #[derive(Deserialize)]
349 struct Row {
350 object: String,
351 }
352 let row = self
353 .db
354 .prepare("UPDATE cache_entries SET upload = ? WHERE id = ? AND repo_id = ? AND status = 'pending' RETURNING object")
355 .bind(&[a.upload.as_str().into(), a.id.as_str().into(), job.repo_id.as_str().into()])?
356 .first::<Row>(None)
357 .await?;
358 row.map(|r| r.object)
359 }
360 "artifact" => {
361 #[derive(Deserialize)]
362 struct Row {
363 object: String,
364 }
365 let id: f64 = a.id.parse().unwrap_or(-1.0);
366 self.db
367 .prepare("SELECT object FROM artifacts WHERE id = ? AND run_id = ? AND status = 'pending'")
368 .bind(&[id.into(), job.run_id.as_str().into()])?
369 .first::<Row>(None)
370 .await?
371 .map(|r| r.object)
372 }
373 _ => None,
374 };
375 let Some(object) = object else {
376 return Ok(fail(FailureCode::NotFound, "No upload of that is in progress."));
377 };
378 let claims = BlobClaims { k: a.kind, i: a.id, o: object, u: Some(a.upload), e: now_ms() / 1000 + UPLOAD_SECONDS };
379 Ok(Outcome::Ok(sign_blob(&key, &claims)))
380 }
381
382 /// An upload token for an entry whose R2 upload has been started.
383 pub(crate) fn upload_token(&self, kind: &str, id: &str, object: &str, upload: &str) -> Option<String> {
384 let key = self.blob_signing_key()?;
385 let claims = BlobClaims { k: kind.to_owned(), i: id.to_owned(), o: object.to_owned(), u: Some(upload.to_owned()), e: now_ms() / 1000 + UPLOAD_SECONDS };
386 Some(sign_blob(&key, &claims))
387 }
388
389 /// A download token for an entry, good for `seconds`.
390 pub(crate) fn download_token(&self, kind: &str, id: &str, object: &str, seconds: u64) -> Option<String> {
391 let key = self.blob_signing_key()?;
392 Some(sign_blob(&key, &BlobClaims { k: kind.to_owned(), i: id.to_owned(), o: object.to_owned(), u: None, e: now_ms() / 1000 + seconds }))
393 }
394
395 fn opened(&self, token: &str) -> Option<BlobClaims> {
396 open_blob(&self.blob_signing_key()?, token, now_ms() / 1000)
397 }
398
399 /// `blob_open`.
400 pub async fn blob_open(&self, a: BlobArgs) -> Result<Outcome<BlobGrant>> {
401 let Some(claims) = self.opened(&a.blob) else {
402 return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not one of g1t's."));
403 };
404 // A download needs its entry still there; an upload, still pending.
405 let wanted = if claims.u.is_some() { "pending" } else { "ready" };
406 let (exists, filename, content_type) = match claims.k.as_str() {
407 "cache" => {
408 let row = self
409 .db
410 .prepare("SELECT id FROM cache_entries WHERE id = ? AND object = ? AND status = ?")
411 .bind(&[claims.i.as_str().into(), claims.o.as_str().into(), wanted.into()])?
412 .first::<Value>(None)
413 .await?;
414 (row.is_some(), None, Some("application/octet-stream".to_owned()))
415 }
416 _ => {
417 #[derive(Deserialize)]
418 struct Row {
419 name: String,
420 format: String,
421 }
422 let id: f64 = claims.i.parse().unwrap_or(-1.0);
423 let row = self
424 .db
425 .prepare("SELECT name, format FROM artifacts WHERE id = ? AND object = ? AND status = ?")
426 .bind(&[id.into(), claims.o.as_str().into(), wanted.into()])?
427 .first::<Row>(None)
428 .await?;
429 match row {
430 Some(row) => {
431 let (extension, kind) = if row.format == "tgz" { ("tar.gz", "application/gzip") } else { ("zip", "application/zip") };
432 (true, Some(format!("{}.{extension}", row.name)), Some(kind.to_owned()))
433 }
434 None => (false, None, None),
435 }
436 }
437 };
438 if !exists {
439 return Ok(fail(FailureCode::NotFound, "That is gone: it expired, was deleted, or its upload finished."));
440 }
441 Ok(Outcome::Ok(BlobGrant { kind: claims.k, id: claims.i, object: claims.o, upload: claims.u, filename, content_type }))
442 }
443
444 /// `blob_part`.
445 pub async fn blob_part(&self, a: BlobArgs) -> Result<Outcome<bool>> {
446 let Some(BlobClaims { u: Some(upload), .. }) = self.opened(&a.blob) else {
447 return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not an upload."));
448 };
449 self.db
450 .prepare(
451 "INSERT INTO blob_parts (upload, part, etag, size, created_at) VALUES (?1, ?2, ?3, ?4, ?5)
452 ON CONFLICT (upload, part) DO UPDATE SET etag = ?3, size = ?4, created_at = ?5",
453 )
454 .bind(&[upload.as_str().into(), f64::from(a.part).into(), a.etag.as_str().into(), (a.size as f64).into(), rfc3339(now_ms()).into()])?
455 .run()
456 .await?;
457 Ok(Outcome::Ok(true))
458 }
459
460 /// `blob_parts`.
461 pub async fn blob_parts(&self, a: BlobArgs) -> Result<Outcome<Vec<BlobPart>>> {
462 let Some(BlobClaims { u: Some(upload), .. }) = self.opened(&a.blob) else {
463 return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not an upload."));
464 };
465 #[derive(Deserialize)]
466 struct Row {
467 part: f64,
468 etag: String,
469 size: f64,
470 }
471 let rows = self
472 .db
473 .prepare("SELECT part, etag, size FROM blob_parts WHERE upload = ? ORDER BY part")
474 .bind(&[upload.as_str().into()])?
475 .all()
476 .await?
477 .results::<Row>()?;
478 Ok(Outcome::Ok(rows.into_iter().map(|r| BlobPart { part: r.part as u32, etag: r.etag, size: r.size as u64 }).collect()))
479 }
480
481 /// `blob_done`: the upload is complete at `size` bytes, as R2 measured
482 /// it, which is the size its entry is committed at.
483 pub async fn blob_done(&self, a: BlobArgs) -> Result<Outcome<bool>> {
484 let Some(BlobClaims { k, i, u: Some(upload), .. }) = self.opened(&a.blob) else {
485 return Ok(Outcome::Ok(false));
486 };
487 self.db.prepare("DELETE FROM blob_parts WHERE upload = ?").bind(&[upload.as_str().into()])?.run().await?;
488 let size = (a.size as f64).into();
489 if k == "cache" {
490 self.db
491 .prepare("UPDATE cache_entries SET size = ? WHERE id = ? AND status = 'pending'")
492 .bind(&[size, i.as_str().into()])?
493 .run()
494 .await?;
495 } else {
496 self.db
497 .prepare("UPDATE artifacts SET size = ? WHERE id = ? AND status = 'pending'")
498 .bind(&[size, i.parse::<f64>().unwrap_or(-1.0).into()])?
499 .run()
500 .await?;
501 }
502 Ok(Outcome::Ok(true))
503 }
504
505 /// Hourly: parts of uploads abandoned long ago.
506 pub(crate) async fn sweep_blob_parts(&self, now: u64) -> Result<()> {
507 self.db
508 .prepare("DELETE FROM blob_parts WHERE created_at < ?")
509 .bind(&[rfc3339(now.saturating_sub(UPLOAD_SECONDS * 1000 * 2)).into()])?
510 .run()
511 .await?;
512 Ok(())
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 const HASH: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
521
522 #[test]
523 fn a_runtime_token_is_a_jwt_the_toolkit_reads() {
524 let token = runtime_token("job_1", "run_1", HASH, 1_700_000_000, 3600);
525 let parts: Vec<&str> = token.split('.').collect();
526 assert_eq!(parts.len(), 3);
527 let header = decode_json(parts[0]).unwrap();
528 assert_eq!(header["alg"], "HS256");
529 let claims = decode_json(parts[1]).unwrap();
530 // @actions/artifact finds the run and job in the `Actions.Results`
531 // scope: three parts split on `:`.
532 let results = claims["scp"].as_str().unwrap().split(' ').find(|s| s.starts_with("Actions.Results:")).unwrap();
533 assert_eq!(results.split(':').collect::<Vec<_>>(), ["Actions.Results", "run_1", "job_1"]);
534 assert_eq!(runtime_job(&token).as_deref(), Some("job_1"));
535 assert!(is_runtime(&token) && !is_runtime(HASH));
536 }
537
538 #[test]
539 fn a_runtime_token_is_checked_against_its_job() {
540 let token = runtime_token("job_1", "run_1", HASH, 1_700_000_000, 3600);
541 assert!(runtime_valid(&token, "job_1", HASH, 1_700_000_100));
542 // Another job, another job's key, too late.
543 assert!(!runtime_valid(&token, "job_2", HASH, 1_700_000_100));
544 assert!(!runtime_valid(&token, "job_1", &"0".repeat(64), 1_700_000_100));
545 assert!(!runtime_valid(&token, "job_1", HASH, 1_700_003_601));
546 // Claims changed without the key.
547 let parts: Vec<&str> = token.split('.').collect();
548 let forged = encode(json!({ "job": "job_1", "run": "run_9", "exp": 9_999_999_999u64 }).to_string().as_bytes());
549 assert!(!runtime_valid(&format!("{}.{forged}.{}", parts[0], parts[2]), "job_1", HASH, 1_700_000_100));
550 assert!(!runtime_valid("a.b", "job_1", HASH, 0));
551 }
552
553 #[test]
554 fn blob_tokens_are_signed_and_expire() {
555 let key = blob_key("00".repeat(32).as_str());
556 let claims = BlobClaims { k: "artifact".into(), i: "12".into(), o: "a/repo_1/12".into(), u: Some("up".into()), e: 1_000 };
557 let token = sign_blob(&key, &claims);
558 assert_eq!(open_blob(&key, &token, 999), Some(claims));
559 assert_eq!(open_blob(&key, &token, 1_000), None);
560 assert_eq!(open_blob(&blob_key("11".repeat(32).as_str()), &token, 999), None);
561 let (payload, _) = token.split_once('.').unwrap();
562 assert_eq!(open_blob(&key, &format!("{payload}.AAAA"), 999), None);
563 }
564
565 #[test]
566 fn id_token_needs_write_in_the_job_or_else_the_workflow() {
567 let wf = |p: Value| json!({ "permissions": p });
568 let none = json!({});
569 assert!(!id_token_permitted(&none, &none));
570 assert!(id_token_permitted(&wf(json!({ "id-token": "write", "contents": "read" })), &none));
571 assert!(!id_token_permitted(&wf(json!({ "id-token": "read" })), &none));
572 assert!(id_token_permitted(&wf(json!("write-all")), &none));
573 assert!(!id_token_permitted(&wf(json!("read-all")), &none));
574 // The job's own permissions replace the workflow's entirely.
575 assert!(!id_token_permitted(&wf(json!({ "id-token": "write" })), &json!({ "permissions": { "contents": "read" } })));
576 assert!(id_token_permitted(&wf(json!({})), &json!({ "permissions": { "id-token": "write" } })));
577 assert!(!id_token_permitted(&none, &json!({ "permissions": {} })));
578 }
579
580 #[test]
581 fn subjects_are_github_s() {
582 assert_eq!(subject("acme/web", None, "push", "refs/heads/main"), "repo:acme/web:ref:refs/heads/main");
583 assert_eq!(subject("acme/web", None, "push", "refs/tags/v1"), "repo:acme/web:ref:refs/tags/v1");
584 assert_eq!(subject("acme/web", Some("prod"), "push", "refs/heads/main"), "repo:acme/web:environment:prod");
585 assert_eq!(subject("acme/web", None, "pull_request", "refs/pull/3/merge"), "repo:acme/web:pull_request");
586 assert_eq!(subject("acme/web", None, "pull_request_target", "refs/heads/main"), "repo:acme/web:pull_request");
587 // An environment wins over a pull request, as on GitHub.
588 assert_eq!(subject("acme/web", Some("preview"), "pull_request", "refs/pull/3/merge"), "repo:acme/web:environment:preview");
589 }
590
591 #[test]
592 fn environments_are_named_plainly_or_not_at_all() {
593 assert_eq!(environment_name(&json!({ "environment": "production" })).as_deref(), Some("production"));
594 assert_eq!(environment_name(&json!({ "environment": { "name": "staging", "url": "x" } })).as_deref(), Some("staging"));
595 assert_eq!(environment_name(&json!({ "environment": "${{ inputs.env }}" })), None);
596 assert_eq!(environment_name(&json!({})), None);
597 }
598
599 #[test]
600 fn claims_carry_what_trust_policies_read() {
601 let info = RunInfo {
602 repository: "acme/web".into(),
603 repository_id: "repo_1".into(),
604 default_branch: "main".into(),
605 event_name: "push".into(),
606 git_ref: "refs/heads/main".into(),
607 sha: "abc".into(),
608 actor: "ada".into(),
609 actor_id: "usr_1".into(),
610 run_id: "run_1".into(),
611 run_number: 7,
612 run_attempt: 2,
613 workflow: "Deploy".into(),
614 workflow_path: ".g1t/workflows/deploy.yml".into(),
615 ..RunInfo::default()
616 };
617 let facts = ClaimFacts {
618 info: &info,
619 environment: Some("production"),
620 job_workflow_path: ".g1t/workflows/release.yml",
621 private: true,
622 owner_id: "ws_1",
623 self_hosted: false,
624 };
625 let c = claims(&facts);
626 assert_eq!(c["sub"], "repo:acme/web:environment:production");
627 assert_eq!(c["environment"], "production");
628 assert_eq!(c["repository_owner"], "acme");
629 assert_eq!(c["repository_owner_id"], "ws_1");
630 assert_eq!(c["repository_visibility"], "private");
631 assert_eq!(c["run_number"], "7");
632 assert_eq!(c["run_attempt"], "2");
633 assert_eq!(c["workflow_ref"], "acme/web/.g1t/workflows/deploy.yml@refs/heads/main");
634 assert_eq!(c["job_workflow_ref"], "acme/web/.g1t/workflows/release.yml@refs/heads/main");
635 assert_eq!(c["ref_type"], "branch");
636 assert_eq!(c["ref_protected"], "true");
637 assert_eq!(c["runner_environment"], "github-hosted");
638 for absent in ["iss", "aud", "exp", "iat", "jti"] {
639 assert!(c.get(absent).is_none(), "{absent} is the API's to add");
640 }
641 }
642}