Skip to content

Commit

A workflow that deploys to AWS, Google Cloud or Azure with the job's OIDC token now says how to reach the cloud's token service, and the docs say where to look when a job has no token. The issuer, its keys and the token endpoint were already live at https://api.g1t.sh/actions/oidc, and a job with `permissions: id-token: write` already got ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN. But on g1t's machines a job reaches only the hosts its project allows, and no cloud's token exchange is among them, so a login step following the docs failed with a bare 403. The OIDC section of the Actions guide has a new part, Reaching your cloud. It lists the hosts each cloud's login needs (sts.<region>.amazonaws.com; sts.googleapis.com and iamcredentials.googleapis.com; login.microsoftonline.com and management.azure.com) and says to add them as workflow-only domains limited to the deploying workflow and environment. Each cloud's steps now include that step, and the guide says what a job without `id-token: write` sees. The secrets section of the Actions guide, Secrets and variables, and the guardrails page's workflow-only domains each point to OIDC tokens as the way to deploy without keeping a cloud key.

Two tests cover what was untested. The runner checks that a step's process gets both OIDC variables as the API sent them: the URL is not touched by path relocation, and the token is not dropped along with the runner's own credentials. The API checks how a token's audience is chosen: the one asked for, percent-encoded as the toolkit sends it (Azure's api:// and Google's // provider names included), or the owner's address when none is given. That choice is now a function of its own in oidc.rs, so it can be tested.

syntaqxcommitted Parent17a14e1Browse 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 }