Impersonate the service account

The federated token carries no roles. The runner uses it to ask IAM Credentials for an access token as the service account, and IAM agrees only if a binding grants this repository roles/iam.workloadIdentityUser on it: the authorization hinge of the whole pattern.

Call sequence

runner forgejo ci forgejo forgejo.authlab.app STS sts.googleapis.com iam credentials iamcredentials.googleapis.com cloud storage storage.googleapis.com step 1 GET /idtoken 200 · oidc jwt step 2 POST /token GET discovery GET JWKS 200 · federated step 3 1 POST :generateAccessToken 2 200 · sa token
  • the federated token asks IAM to mint an access token as the service account
  • IAM grants it only if a binding names this repository on the service account
  • an ordinary service account token: from here on, GCP sees only the service account
  • the token value is a placeholder; real ones are opaque ya29.* strings, not JWTs
request POST
curl -X POST https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/ci-deployer@authlab-lab.iam.gserviceaccount.com:generateAccessToken \
    -H 'Authorization: Bearer ya29.c.REDACTED-federated-access-token-not-valid-for-use' \
    -H 'Content-Type: application/json' \
    -d '{
      "scope": ["https://www.googleapis.com/auth/cloud-platform"],
      "lifetime": "3600s"
    }'
response 200 OK
{
  "accessToken": "ya29.c.SYNTHETIC-sa-access-token-not-valid-for-use",
  "expireTime": "2026-05-12T21:46:34Z"
}

What IAM checks

The binding IAM evaluates

principalSet://iam.googleapis.com/
projects/1234567890/
locations/global/
workloadIdentityPools/lab-pool/
attribute.repository/authlab/artifacts-visualizer
≡ token repository: the binding pins this repo (any ref)
$ gcloud iam service-accounts add-iam-policy-binding \
    ci-deployer@authlab-lab.iam.gserviceaccount.com \
    --role="roles/iam.workloadIdentityUser" \
    --member="principalSet://iam.googleapis.com/projects/1234567890/locations/global/workloadIdentityPools/lab-pool/attribute.repository/authlab/artifacts-visualizer"

run once, by an operator — not part of the runtime flow

No binding that names this repository → 403. Step 2 proved who; this binding decides what they may impersonate.