Skip to content

Commit

Merge the OIDC docs: deploying to AWS, Google Cloud or Azure with a job's short-lived identity token now says which of the cloud's hosts to allow under Workflow-only domains, for the deploying workflow and environment, since a job on g1t's machines reaches only the hosts its project allows; and tests for the audience a job asks for and for the token variables reaching each step.

syntaqxcommitted Parentsc549fe15f83f32Browse files
5 files+110−120/5 viewed
+28−9
205205 claims
206206 }
207207
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.
211+pub 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+
208219 /// The header and claims, base64url-encoded and joined: what is signed.
209220 pub fn signing_input(kid: &str, claims: &Value) -> String {
210221 let header = json!({ "typ": "JWT", "alg": "RS256", "kid": kid, "x5t": kid });
264275 return error(status, &refused.message);
265276 }
266277 };
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));
278+ let owner = claims["repository_owner"].as_str().unwrap_or_default();
279+ let audience = audience(&request.url()?, &services.addresses.site, owner);
276280 let now = g1t_kit::now_ms() / 1000;
277281 let jti = g1t_contracts::new_id("oidc", g1t_kit::now_ms());
278282 let claims = full_claims(claims, &issuer(&api), &audience, &jti, now);
323327 }
324328
325329 #[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]
326345 fn a_key_that_is_not_one_is_refused() {
327346 assert!(SigningKey::from_pem("hello").is_err());
328347 let pem = format!("{}\nAAAA\n{}", concat!("-----BEGIN ", "PRIVATE KEY-----"), concat!("-----END ", "PRIVATE KEY-----"));
+44−3
10681068 | Lifetime | 5 minutes |
10691069 | Audience | What the job asks for; `https://g1t.sh/<owner>` when it asks for none |
10701070
1071+A job without `id-token: write` has no `ACTIONS_ID_TOKEN_REQUEST_URL`, so
1072+a login action says it cannot find one; a token asked for anyway is
1073+refused with `403` and `This job has no OIDC token`.
1074+
1075+### Reaching your cloud
1076+
1077+Asking for the token goes to `api.g1t.sh`, which every job reaches. Trading
1078+it for credentials goes to your cloud's token service, which a job reaches
1079+only when its project allows it (see [what a job can reach](#what-a-job-can-reach)).
1080+Add the hosts under **Settings → Guardrails → Workflow-only domains**,
1081+limited to the workflow and environment that deploy, so agents and other
1082+workflows never reach them:
1083+
1084+```text
1085+sts.us-east-1.amazonaws.com | deploy.yml | production
1086+s3.us-east-1.amazonaws.com | deploy.yml | production
1087+sts.googleapis.com | deploy.yml | production
1088+iamcredentials.googleapis.com | deploy.yml | production
1089+login.microsoftonline.com | deploy.yml | production
1090+management.azure.com | deploy.yml | production
1091+```
1092+
1093+| Cloud | Token exchange | Then |
1094+| --- | --- | --- |
1095+| AWS | `sts.<region>.amazonaws.com`, the region `aws-region` names | Each service the job uses, such as `s3.<region>.amazonaws.com`, or `*.amazonaws.com` for all of them |
1096+| Google Cloud | `sts.googleapis.com`, `iamcredentials.googleapis.com` | Each API the job uses, such as `run.googleapis.com` |
1097+| Azure | `login.microsoftonline.com` | `management.azure.com`, and each service the job uses |
1098+
1099+Without them, the login step fails with `403` and a line naming the host
1100+that is not allowed.
1101+
10711102 ### Claims
10721103
10731104 Each token carries the claims GitHub's do, so trust policies written for
11211152 }
11221153 ```
11231154
1124−3. Use `aws-actions/configure-aws-credentials@v4` with `role-to-assume`,
1155+3. Allow `sts.<region>.amazonaws.com`, and what the job deploys to, as
1156+ [workflow-only domains](#reaching-your-cloud).
1157+4. Use `aws-actions/configure-aws-credentials@v4` with `role-to-assume`,
11251158 as above.
11261159
11271160 ### Google Cloud
11451178 --member="principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/g1t/attribute.repository/acme/web"
11461179 ```
11471180
1148−3. Use `google-github-actions/auth@v2` with
1181+3. Allow `sts.googleapis.com` and `iamcredentials.googleapis.com`, and
1182+ the APIs the job uses, as [workflow-only domains](#reaching-your-cloud).
1183+4. Use `google-github-actions/auth@v2` with
11491184 `workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/g1t/providers/g1t`
11501185 and `service_account`. It asks for the provider's own name as the
11511186 audience, which the provider accepts unless you change its allowed
11691204 ```
11701205
11711206 2. Give it a role on what it deploys to, as for any identity.
1172−3. Use `azure/login@v2` with `client-id`, `tenant-id` and
1207+3. Allow `login.microsoftonline.com` and `management.azure.com` as
1208+ [workflow-only domains](#reaching-your-cloud).
1209+4. Use `azure/login@v2` with `client-id`, `tenant-id` and
11731210 `subscription-id`, and no secret.
11741211
11751212 A federated credential matches the subject exactly: add one per
16811718 [Secrets and variables](/guides/secrets-and-variables/) for how rows,
16821719 environments and the workspace's rows work.
16831720
1721+To deploy to AWS, Google Cloud or Azure, or to a server of your own,
1722+you need no secret at all: give the job `permissions: id-token: write`
1723+and trade its [OIDC token](#oidc-tokens) for short-lived credentials.
1724+
16841725 Every job also gets `${{ secrets.G1T_TOKEN }}`, [its own token](#the-jobs-token),
16851726 with `GITHUB_TOKEN` as its alias. A pull request's runs get secrets only
16861727 when its author has the Write [role](/guides/access-and-roles/) or higher
+7−0
161161 `update_guardrails`, saying which domains were added or removed and what
162162 they were limited to.
163163
164+A deploy that signs in to AWS, Google Cloud or Azure with the job's
165+[OIDC token](/guides/actions/#oidc-tokens), rather than a key kept in a
166+secret, needs its cloud's token service listed here, such as
167+`sts.us-east-1.amazonaws.com | deploy.yml | production`. See
168+[reaching your cloud](/guides/actions/#reaching-your-cloud) for each
169+cloud's hosts.
170+
164171 ## Commands
165172
166173 The agent's harness checks every tool call the agent makes before it runs.
+4−0
1717 values included, are for Admins of its repository, and the page is not
1818 shown to anyone else.
1919
20+A workflow that deploys to AWS, Google Cloud or Azure needs no cloud key
21+here: it can sign in with the job's short-lived
22+[OIDC token](/guides/actions/#oidc-tokens) instead.
23+
2024 ## A row
2125
2226 | Field | |
+27−0
14201420 assert_eq!(step_timeout(None), Duration::from_secs(6 * 3600));
14211421 assert_eq!(step_timeout(Some(-1.0)), Duration::ZERO);
14221422 }
1423+
1424+ /// `core.getIDToken()` reads the OIDC variables from the step's own
1425+ /// environment, so they have to reach every step's process as the API
1426+ /// put them in the spec: the URL untouched by path relocation, and the
1427+ /// token not dropped with the runner's own credentials.
1428+ #[test]
1429+ fn a_steps_process_gets_the_oidc_request_variables() {
1430+ let dir = std::env::temp_dir().join(format!("g1t-oidc-env-{}-{}", std::process::id(), rand_id()));
1431+ let url = "https://api.g1t.sh/actions/oidc/token?api-version=2.0";
1432+ let spec = json!({
1433+ "variables": {
1434+ "ACTIONS_RUNTIME_TOKEN": "h.p.s",
1435+ "ACTIONS_ID_TOKEN_REQUEST_URL": url,
1436+ "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "h.p.s",
1437+ },
1438+ "github": {},
1439+ "event": {},
1440+ "masks": ["h.p.s"],
1441+ });
1442+ let job = setup_in(spec, Api::offline(), dir.join("work"), dir.join("temp")).unwrap();
1443+ let files = StepFiles::new(&job.temp, "oidc").unwrap();
1444+ let env = job.process_env(&BTreeMap::new(), &files);
1445+ assert_eq!(env.get("ACTIONS_ID_TOKEN_REQUEST_URL").map(String::as_str), Some(url));
1446+ assert_eq!(env.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN").map(String::as_str), Some("h.p.s"));
1447+ assert_eq!(env.get("ACTIONS_RUNTIME_TOKEN").map(String::as_str), Some("h.p.s"));
1448+ let _ = std::fs::remove_dir_all(&dir);
1449+ }
14231450 }