Skip to content
637 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///
Merge branch 'worktree-agent-a3abfcce648e87dca'154/// Read with the same model as the job's token (`g1t_actions::permissions`).
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2155pub(crate) fn id_token_permitted(workflow: &Value, job: &Value) -> bool {
Merge branch 'worktree-agent-a3abfcce648e87dca'156 use g1t_actions::permissions::{self, Access};
157 // A job's own `permissions:` replace the workflow's; without either,
158 // no default gives `id-token`, as on GitHub.
159 let written = |spec: &Value| spec.get("permissions").and_then(|value| permissions::parse(value).ok()).map(|(read, _)| read);
160 written(job).or_else(|| written(workflow)).is_some_and(|granted| granted.get("id-token") == Access::Write)
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2161}
162
163/// A job's `environment:` name, when it names one plainly.
164pub(crate) fn environment_name(job: &Value) -> Option<String> {
165 match job.get("environment") {
166 Some(Value::String(name)) if !name.contains("${{") && !name.trim().is_empty() => Some(name.trim().to_owned()),
167 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()),
168 _ => None,
169 }
170}
171
172/// GitHub's subject: by environment, else for a pull request, else by ref.
173pub fn subject(repository: &str, environment: Option<&str>, event: &str, git_ref: &str) -> String {
174 if let Some(environment) = environment {
175 return format!("repo:{repository}:environment:{environment}");
176 }
177 if event == "pull_request" || event == "pull_request_target" {
178 return format!("repo:{repository}:pull_request");
179 }
180 format!("repo:{repository}:ref:{git_ref}")
181}
182
183/// What an OIDC token says about a job, besides who issued it, for whom
184/// and when.
185pub(crate) struct ClaimFacts<'a> {
186 pub info: &'a RunInfo,
187 pub environment: Option<&'a str>,
188 /// The workflow that defines the job: a called workflow's own path.
189 pub job_workflow_path: &'a str,
190 pub private: bool,
191 pub owner_id: &'a str,
192 pub self_hosted: bool,
193}
194
195pub(crate) fn claims(facts: &ClaimFacts) -> Value {
196 let info = facts.info;
197 let owner = info.repository.split('/').next().unwrap_or_default();
198 let workflow_ref = format!("{}/{}@{}", info.repository, info.workflow_path, info.git_ref);
199 let job_workflow_ref = format!("{}/{}@{}", info.repository, facts.job_workflow_path, info.git_ref);
200 let mut claims = json!({
201 "sub": subject(&info.repository, facts.environment, &info.event_name, &info.git_ref),
202 "ref": info.git_ref,
203 "sha": info.sha,
204 "repository": info.repository,
205 "repository_owner": owner,
206 "repository_owner_id": facts.owner_id,
207 "repository_id": info.repository_id,
208 "repository_visibility": if facts.private { "private" } else { "public" },
209 "run_id": info.run_id,
210 "run_number": info.run_number.to_string(),
211 "run_attempt": info.run_attempt.to_string(),
212 "actor": info.actor,
213 "actor_id": info.actor_id,
214 "workflow": info.workflow,
215 "workflow_ref": workflow_ref,
216 "workflow_sha": info.sha,
217 "job_workflow_ref": job_workflow_ref,
218 "job_workflow_sha": info.sha,
219 "head_ref": info.head_ref.clone().unwrap_or_default(),
220 "base_ref": info.base_ref.clone().unwrap_or_default(),
221 "event_name": info.event_name,
222 "ref_type": info.ref_type(),
223 "ref_protected": (info.ref_name() == info.default_branch).to_string(),
224 "runner_environment": if facts.self_hosted { "self-hosted" } else { "github-hosted" },
225 });
226 if let Some(environment) = facts.environment {
227 claims["environment"] = json!(environment);
228 }
229 claims
230}
231
232impl Actions {
233 /// The running job a runtime token is for.
234 pub(crate) async fn job_for_runtime(&self, job: &str, token: &str) -> Result<Outcome<JobRow>> {
235 let row = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job.into()])?.first::<JobRow>(None).await?;
236 Ok(match row {
237 Some(row)
238 if row.status == "in_progress"
239 && row.token_hash.as_deref().is_some_and(|hash| runtime_valid(token, job, hash, now_ms() / 1000)) =>
240 {
241 Outcome::Ok(row)
242 }
243 _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."),
244 })
245 }
246
247 /// The running job a sandbox's credential is for: its job's own token,
248 /// or its runtime token. Only for the cache and artifacts: a runtime
249 /// token never reads a job's spec or reports for it.
250 pub(crate) async fn job_for_credential(&self, job: &str, token: &str) -> Result<Outcome<JobRow>> {
251 if is_runtime(token) {
252 return self.job_for_runtime(job, token).await;
253 }
254 let row = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job.into()])?.first::<JobRow>(None).await?;
255 Ok(match row {
256 Some(row) if row.status == "in_progress" && row.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(token))) => Outcome::Ok(row),
257 _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."),
258 })
259 }
260
261 /// `runtime_auth`.
262 pub async fn runtime_auth(&self, a: RuntimeAuthArgs) -> Result<Outcome<RuntimeJob>> {
263 let job = check!(self.job_for_runtime(&a.job, &a.token).await?);
264 let Some(run) = self.run_row(&job.run_id).await? else {
265 return Ok(fail(FailureCode::NotFound, "No such run."));
266 };
267 Ok(Outcome::Ok(RuntimeJob { job: job.id, run: job.run_id, repo_id: job.repo_id, namespace: job.namespace, repository: run.repo }))
268 }
269
270 /// `oidc_claims`.
271 pub async fn oidc_claims(&self, a: RuntimeAuthArgs) -> Result<Outcome<Value>> {
272 let job = check!(self.job_for_runtime(&a.job, &a.token).await?);
273 let Some(run) = self.run_row(&job.run_id).await? else {
274 return Ok(fail(FailureCode::NotFound, "No such run."));
275 };
276 let Some(permitted) = self.oidc_permitted(&run, &job) else {
277 return Ok(fail(FailureCode::Forbidden, "The workflow no longer reads."));
278 };
279 if !permitted.0 {
280 return Ok(fail(
281 FailureCode::Forbidden,
282 "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).",
283 ));
284 }
285 let (repo, _) = match self.repo_by_id(&run.repo_id).await? {
286 Some(found) => found,
287 None => return Ok(fail(FailureCode::NotFound, "There is no such repository.")),
288 };
289 let info = run.info();
290 let facts = ClaimFacts {
291 info: &info,
292 environment: permitted.1.as_deref(),
293 job_workflow_path: &permitted.2,
294 private: repo.is_private,
295 owner_id: &repo.owner_id,
296 self_hosted: job.runner_id.is_some(),
297 };
298 Ok(Outcome::Ok(claims(&facts)))
299 }
300
301 /// Whether a job may have an OIDC token, its environment, and the path
302 /// of the workflow that defines it. A called workflow's job needs both
303 /// its own permissions and its caller's to allow it, as on GitHub. A
304 /// run that is not trusted (a pull request from outside) never may.
305 /// `None` when the workflow no longer reads.
306 fn oidc_permitted(&self, run: &RunRow, job: &JobRow) -> Option<(bool, Option<String>, String)> {
307 let caller = g1t_actions::workflow::parse(&run.source).ok()?;
308 let trusted = run.trusted != 0;
309 match job.callee() {
310 Some((called, spec, call)) => {
311 let parent = call["parent"].as_str().unwrap_or_default();
312 let caller_allows = caller.jobs.iter().find(|j| j.id == parent).is_none_or(|j| id_token_permitted(&caller.raw, &j.raw));
313 let path = call["path"].as_str().unwrap_or(&run.path).to_owned();
Merge branch 'worktree-agent-a3abfcce648e87dca'314 let environment = job.environment.clone().or_else(|| environment_name(&spec.raw));
315 Some((trusted && caller_allows && id_token_permitted(&called.raw, &spec.raw), environment, path))
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2316 }
317 None => {
318 let spec = caller.jobs.iter().find(|j| j.id == job.key)?;
Merge branch 'worktree-agent-a3abfcce648e87dca'319 // As read when its needs were done, an expression's included.
320 let environment = job.environment.clone().or_else(|| environment_name(&spec.raw));
321 Some((trusted && id_token_permitted(&caller.raw, &spec.raw), environment, run.path.clone()))
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2322 }
323 }
324 }
325
326 /// Whether a job may have an OIDC token, for its spec.
327 pub(crate) fn oidc_allowed(&self, run: &RunRow, job: &JobRow) -> bool {
328 self.oidc_permitted(run, job).is_some_and(|p| p.0)
329 }
330
331 fn blob_signing_key(&self) -> Option<String> {
332 self.blob_key.clone()
333 }
334
335 /// `blob_sign`.
336 pub async fn blob_sign(&self, a: BlobSignArgs) -> Result<Outcome<String>> {
337 let job = check!(self.job_for_credential(&a.job, &a.token).await?);
338 let Some(key) = self.blob_signing_key() else {
339 return Ok(fail(FailureCode::Invalid, "The toolkit's storage is not set up here: the actions service has no ACTIONS_KEY."));
340 };
341 let object = match a.kind.as_str() {
342 "cache" => {
343 #[derive(Deserialize)]
344 struct Row {
345 object: String,
346 }
347 let row = self
348 .db
349 .prepare("UPDATE cache_entries SET upload = ? WHERE id = ? AND repo_id = ? AND status = 'pending' RETURNING object")
350 .bind(&[a.upload.as_str().into(), a.id.as_str().into(), job.repo_id.as_str().into()])?
351 .first::<Row>(None)
352 .await?;
353 row.map(|r| r.object)
354 }
355 "artifact" => {
356 #[derive(Deserialize)]
357 struct Row {
358 object: String,
359 }
360 let id: f64 = a.id.parse().unwrap_or(-1.0);
361 self.db
362 .prepare("SELECT object FROM artifacts WHERE id = ? AND run_id = ? AND status = 'pending'")
363 .bind(&[id.into(), job.run_id.as_str().into()])?
364 .first::<Row>(None)
365 .await?
366 .map(|r| r.object)
367 }
368 _ => None,
369 };
370 let Some(object) = object else {
371 return Ok(fail(FailureCode::NotFound, "No upload of that is in progress."));
372 };
373 let claims = BlobClaims { k: a.kind, i: a.id, o: object, u: Some(a.upload), e: now_ms() / 1000 + UPLOAD_SECONDS };
374 Ok(Outcome::Ok(sign_blob(&key, &claims)))
375 }
376
377 /// An upload token for an entry whose R2 upload has been started.
378 pub(crate) fn upload_token(&self, kind: &str, id: &str, object: &str, upload: &str) -> Option<String> {
379 let key = self.blob_signing_key()?;
380 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 };
381 Some(sign_blob(&key, &claims))
382 }
383
384 /// A download token for an entry, good for `seconds`.
385 pub(crate) fn download_token(&self, kind: &str, id: &str, object: &str, seconds: u64) -> Option<String> {
386 let key = self.blob_signing_key()?;
387 Some(sign_blob(&key, &BlobClaims { k: kind.to_owned(), i: id.to_owned(), o: object.to_owned(), u: None, e: now_ms() / 1000 + seconds }))
388 }
389
390 fn opened(&self, token: &str) -> Option<BlobClaims> {
391 open_blob(&self.blob_signing_key()?, token, now_ms() / 1000)
392 }
393
394 /// `blob_open`.
395 pub async fn blob_open(&self, a: BlobArgs) -> Result<Outcome<BlobGrant>> {
396 let Some(claims) = self.opened(&a.blob) else {
397 return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not one of g1t's."));
398 };
399 // A download needs its entry still there; an upload, still pending.
400 let wanted = if claims.u.is_some() { "pending" } else { "ready" };
401 let (exists, filename, content_type) = match claims.k.as_str() {
402 "cache" => {
403 let row = self
404 .db
405 .prepare("SELECT id FROM cache_entries WHERE id = ? AND object = ? AND status = ?")
406 .bind(&[claims.i.as_str().into(), claims.o.as_str().into(), wanted.into()])?
407 .first::<Value>(None)
408 .await?;
409 (row.is_some(), None, Some("application/octet-stream".to_owned()))
410 }
411 _ => {
412 #[derive(Deserialize)]
413 struct Row {
414 name: String,
415 format: String,
416 }
417 let id: f64 = claims.i.parse().unwrap_or(-1.0);
418 let row = self
419 .db
420 .prepare("SELECT name, format FROM artifacts WHERE id = ? AND object = ? AND status = ?")
421 .bind(&[id.into(), claims.o.as_str().into(), wanted.into()])?
422 .first::<Row>(None)
423 .await?;
424 match row {
425 Some(row) => {
426 let (extension, kind) = if row.format == "tgz" { ("tar.gz", "application/gzip") } else { ("zip", "application/zip") };
427 (true, Some(format!("{}.{extension}", row.name)), Some(kind.to_owned()))
428 }
429 None => (false, None, None),
430 }
431 }
432 };
433 if !exists {
434 return Ok(fail(FailureCode::NotFound, "That is gone: it expired, was deleted, or its upload finished."));
435 }
436 Ok(Outcome::Ok(BlobGrant { kind: claims.k, id: claims.i, object: claims.o, upload: claims.u, filename, content_type }))
437 }
438
439 /// `blob_part`.
440 pub async fn blob_part(&self, a: BlobArgs) -> Result<Outcome<bool>> {
441 let Some(BlobClaims { u: Some(upload), .. }) = self.opened(&a.blob) else {
442 return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not an upload."));
443 };
444 self.db
445 .prepare(
446 "INSERT INTO blob_parts (upload, part, etag, size, created_at) VALUES (?1, ?2, ?3, ?4, ?5)
447 ON CONFLICT (upload, part) DO UPDATE SET etag = ?3, size = ?4, created_at = ?5",
448 )
449 .bind(&[upload.as_str().into(), f64::from(a.part).into(), a.etag.as_str().into(), (a.size as f64).into(), rfc3339(now_ms()).into()])?
450 .run()
451 .await?;
452 Ok(Outcome::Ok(true))
453 }
454
455 /// `blob_parts`.
456 pub async fn blob_parts(&self, a: BlobArgs) -> Result<Outcome<Vec<BlobPart>>> {
457 let Some(BlobClaims { u: Some(upload), .. }) = self.opened(&a.blob) else {
458 return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not an upload."));
459 };
460 #[derive(Deserialize)]
461 struct Row {
462 part: f64,
463 etag: String,
464 size: f64,
465 }
466 let rows = self
467 .db
468 .prepare("SELECT part, etag, size FROM blob_parts WHERE upload = ? ORDER BY part")
469 .bind(&[upload.as_str().into()])?
470 .all()
471 .await?
472 .results::<Row>()?;
473 Ok(Outcome::Ok(rows.into_iter().map(|r| BlobPart { part: r.part as u32, etag: r.etag, size: r.size as u64 }).collect()))
474 }
475
476 /// `blob_done`: the upload is complete at `size` bytes, as R2 measured
477 /// it, which is the size its entry is committed at.
478 pub async fn blob_done(&self, a: BlobArgs) -> Result<Outcome<bool>> {
479 let Some(BlobClaims { k, i, u: Some(upload), .. }) = self.opened(&a.blob) else {
480 return Ok(Outcome::Ok(false));
481 };
482 self.db.prepare("DELETE FROM blob_parts WHERE upload = ?").bind(&[upload.as_str().into()])?.run().await?;
483 let size = (a.size as f64).into();
484 if k == "cache" {
485 self.db
486 .prepare("UPDATE cache_entries SET size = ? WHERE id = ? AND status = 'pending'")
487 .bind(&[size, i.as_str().into()])?
488 .run()
489 .await?;
490 } else {
491 self.db
492 .prepare("UPDATE artifacts SET size = ? WHERE id = ? AND status = 'pending'")
493 .bind(&[size, i.parse::<f64>().unwrap_or(-1.0).into()])?
494 .run()
495 .await?;
496 }
497 Ok(Outcome::Ok(true))
498 }
499
500 /// Hourly: parts of uploads abandoned long ago.
501 pub(crate) async fn sweep_blob_parts(&self, now: u64) -> Result<()> {
502 self.db
503 .prepare("DELETE FROM blob_parts WHERE created_at < ?")
504 .bind(&[rfc3339(now.saturating_sub(UPLOAD_SECONDS * 1000 * 2)).into()])?
505 .run()
506 .await?;
507 Ok(())
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 const HASH: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
516
517 #[test]
518 fn a_runtime_token_is_a_jwt_the_toolkit_reads() {
519 let token = runtime_token("job_1", "run_1", HASH, 1_700_000_000, 3600);
520 let parts: Vec<&str> = token.split('.').collect();
521 assert_eq!(parts.len(), 3);
522 let header = decode_json(parts[0]).unwrap();
523 assert_eq!(header["alg"], "HS256");
524 let claims = decode_json(parts[1]).unwrap();
525 // @actions/artifact finds the run and job in the `Actions.Results`
526 // scope: three parts split on `:`.
527 let results = claims["scp"].as_str().unwrap().split(' ').find(|s| s.starts_with("Actions.Results:")).unwrap();
528 assert_eq!(results.split(':').collect::<Vec<_>>(), ["Actions.Results", "run_1", "job_1"]);
529 assert_eq!(runtime_job(&token).as_deref(), Some("job_1"));
530 assert!(is_runtime(&token) && !is_runtime(HASH));
531 }
532
533 #[test]
534 fn a_runtime_token_is_checked_against_its_job() {
535 let token = runtime_token("job_1", "run_1", HASH, 1_700_000_000, 3600);
536 assert!(runtime_valid(&token, "job_1", HASH, 1_700_000_100));
537 // Another job, another job's key, too late.
538 assert!(!runtime_valid(&token, "job_2", HASH, 1_700_000_100));
539 assert!(!runtime_valid(&token, "job_1", &"0".repeat(64), 1_700_000_100));
540 assert!(!runtime_valid(&token, "job_1", HASH, 1_700_003_601));
541 // Claims changed without the key.
542 let parts: Vec<&str> = token.split('.').collect();
543 let forged = encode(json!({ "job": "job_1", "run": "run_9", "exp": 9_999_999_999u64 }).to_string().as_bytes());
544 assert!(!runtime_valid(&format!("{}.{forged}.{}", parts[0], parts[2]), "job_1", HASH, 1_700_000_100));
545 assert!(!runtime_valid("a.b", "job_1", HASH, 0));
546 }
547
548 #[test]
549 fn blob_tokens_are_signed_and_expire() {
550 let key = blob_key("00".repeat(32).as_str());
551 let claims = BlobClaims { k: "artifact".into(), i: "12".into(), o: "a/repo_1/12".into(), u: Some("up".into()), e: 1_000 };
552 let token = sign_blob(&key, &claims);
553 assert_eq!(open_blob(&key, &token, 999), Some(claims));
554 assert_eq!(open_blob(&key, &token, 1_000), None);
555 assert_eq!(open_blob(&blob_key("11".repeat(32).as_str()), &token, 999), None);
556 let (payload, _) = token.split_once('.').unwrap();
557 assert_eq!(open_blob(&key, &format!("{payload}.AAAA"), 999), None);
558 }
559
560 #[test]
561 fn id_token_needs_write_in_the_job_or_else_the_workflow() {
562 let wf = |p: Value| json!({ "permissions": p });
563 let none = json!({});
564 assert!(!id_token_permitted(&none, &none));
565 assert!(id_token_permitted(&wf(json!({ "id-token": "write", "contents": "read" })), &none));
566 assert!(!id_token_permitted(&wf(json!({ "id-token": "read" })), &none));
567 assert!(id_token_permitted(&wf(json!("write-all")), &none));
568 assert!(!id_token_permitted(&wf(json!("read-all")), &none));
569 // The job's own permissions replace the workflow's entirely.
570 assert!(!id_token_permitted(&wf(json!({ "id-token": "write" })), &json!({ "permissions": { "contents": "read" } })));
571 assert!(id_token_permitted(&wf(json!({})), &json!({ "permissions": { "id-token": "write" } })));
572 assert!(!id_token_permitted(&none, &json!({ "permissions": {} })));
573 }
574
575 #[test]
576 fn subjects_are_github_s() {
577 assert_eq!(subject("acme/web", None, "push", "refs/heads/main"), "repo:acme/web:ref:refs/heads/main");
578 assert_eq!(subject("acme/web", None, "push", "refs/tags/v1"), "repo:acme/web:ref:refs/tags/v1");
579 assert_eq!(subject("acme/web", Some("prod"), "push", "refs/heads/main"), "repo:acme/web:environment:prod");
580 assert_eq!(subject("acme/web", None, "pull_request", "refs/pull/3/merge"), "repo:acme/web:pull_request");
581 assert_eq!(subject("acme/web", None, "pull_request_target", "refs/heads/main"), "repo:acme/web:pull_request");
582 // An environment wins over a pull request, as on GitHub.
583 assert_eq!(subject("acme/web", Some("preview"), "pull_request", "refs/pull/3/merge"), "repo:acme/web:environment:preview");
584 }
585
586 #[test]
587 fn environments_are_named_plainly_or_not_at_all() {
588 assert_eq!(environment_name(&json!({ "environment": "production" })).as_deref(), Some("production"));
589 assert_eq!(environment_name(&json!({ "environment": { "name": "staging", "url": "x" } })).as_deref(), Some("staging"));
590 assert_eq!(environment_name(&json!({ "environment": "${{ inputs.env }}" })), None);
591 assert_eq!(environment_name(&json!({})), None);
592 }
593
594 #[test]
595 fn claims_carry_what_trust_policies_read() {
596 let info = RunInfo {
597 repository: "acme/web".into(),
598 repository_id: "repo_1".into(),
599 default_branch: "main".into(),
600 event_name: "push".into(),
601 git_ref: "refs/heads/main".into(),
602 sha: "abc".into(),
603 actor: "ada".into(),
604 actor_id: "usr_1".into(),
605 run_id: "run_1".into(),
606 run_number: 7,
607 run_attempt: 2,
608 workflow: "Deploy".into(),
609 workflow_path: ".g1t/workflows/deploy.yml".into(),
610 ..RunInfo::default()
611 };
612 let facts = ClaimFacts {
613 info: &info,
614 environment: Some("production"),
615 job_workflow_path: ".g1t/workflows/release.yml",
616 private: true,
617 owner_id: "ws_1",
618 self_hosted: false,
619 };
620 let c = claims(&facts);
621 assert_eq!(c["sub"], "repo:acme/web:environment:production");
622 assert_eq!(c["environment"], "production");
623 assert_eq!(c["repository_owner"], "acme");
624 assert_eq!(c["repository_owner_id"], "ws_1");
625 assert_eq!(c["repository_visibility"], "private");
626 assert_eq!(c["run_number"], "7");
627 assert_eq!(c["run_attempt"], "2");
628 assert_eq!(c["workflow_ref"], "acme/web/.g1t/workflows/deploy.yml@refs/heads/main");
629 assert_eq!(c["job_workflow_ref"], "acme/web/.g1t/workflows/release.yml@refs/heads/main");
630 assert_eq!(c["ref_type"], "branch");
631 assert_eq!(c["ref_protected"], "true");
632 assert_eq!(c["runner_environment"], "github-hosted");
633 for absent in ["iss", "aud", "exp", "iat", "jti"] {
634 assert!(c.get(absent).is_none(), "{absent} is the API's to add");
635 }
636 }
637}

This file's history is long; its oldest lines are credited to the oldest commit read.