OIDC token federation
Each fremforge CI job can receive a short-lived OIDC token: an RS256-signed JWT issued by the fremforge runner controller. Cloud providers (T Cloud, AWS, Azure, GCP) can be configured to trust this token and exchange it for scoped, time-limited credentials. No deploy keys stored as secrets, nothing to rotate on a schedule.
Claims reference
The fremforge runner JWT contains the following claims:
| Claim | Value / format | Example |
|---|---|---|
iss | https://frem.sh/_app/runner/oidc (single global issuer for all tenants) | https://frem.sh/_app/runner/oidc |
sub | repo:<org>/<repo>:ref:refs/heads/<branch> | repo:acme/api:ref:refs/heads/main |
aud | Configurable; default https://frem.sh | https://frem.sh |
repository_owner | Org slug — pin trust-policy conditions on this claim for per-tenant isolation under the shared issuer | acme |
repository | <org>/<repo> | acme/api |
ref | Full git ref | refs/heads/main |
sha | Commit SHA | a1b2c3d4... |
environment | Deployment environment name (if job declares one) | production |
job_workflow_ref | Workflow file path and ref | acme/api/.forgejo/workflows/deploy.yaml@refs/heads/main |
The sub claim is the primary trust anchor in most provider trust policies. It uniquely identifies the org, repo, and branch combination. For per-tenant isolation in shared cloud accounts, add a condition pinning on repository_owner (your org slug) — same pattern as GitHub Actions trust on token.actions.githubusercontent.com. The fremforge runner controller emits a single global issuer for all tenants; trust isolation is enforced at the claim layer, not the issuer-URL layer.
Getting the token in a workflow
Add id-token: write to the job’s permissions block. The runner then populates ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL as environment variables, which you use to fetch the actual JWT:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: fremforge
steps:
- name: Get OIDC token
run: |
TOKEN=$(curl -sS \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://frem.sh" \
| jq -r .value)
echo "TOKEN=$TOKEN" >> $GITHUB_ENVMost provider-specific steps (AWS, Azure, GCP) handle the token fetch internally. You only write the manual fetch above if you need the raw JWT for a custom trust flow.
T Cloud agency trust
T Cloud’s IAM federation model is OpenStack-derived: you register the OIDC issuer as an identity provider, attach a mapping that translates ID-token claims to local user attributes, then exchange the runner’s ID token for a T Cloud token at /v3/auth/tokens and assume an agency scoped to your project. There is no AWS-style trust-policy JSON; scoping happens via the mapping and the agency’s permissions.
Step 1: Create an OpenID Connect identity provider
In the T Cloud console, go to IAM → Identity Providers → Create Identity Provider.
- Type: Virtual user
- Protocol: OpenID Connect
- Access type: Programmatic access (
program)
After creation, configure the OIDC settings:
- Identity provider URL (
idp_url):https://frem.sh/_app/runner/oidc(single global issuer; matches theissclaim on every fremforge runner ID token). - Client ID (
client_id):https://frem.sh, must equal theaudclaim on the runner ID token. - Signing key (
signing_key): paste the JWKS public-key JSON. Fetch it once fromhttps://frem.sh/_app/runner/oidc/.well-known/openid-configuration→jwks_uriand copy the JSON. T Cloud does not auto-fetch JWKS, so re-paste when fremforge announces a JWKS rotation (announced on the trust page; rotations are annual at most).
Step 2: Add a claim mapping
In the same identity provider, add a mapping that translates ID-token claims to local IAM attributes and restricts which subjects can federate:
[
{
"local": [
{
"user": {
"name": "fremforge-{0}-{1}"
}
}
],
"remote": [
{ "type": "repository_owner" },
{ "type": "repository" },
{
"type": "sub",
"any_one_of": [
"repo:<your-org>/<your-repo>:ref:refs/heads/main"
]
}
]
}
]The any_one_of constraint on sub is the scoping equivalent of an AWS trust-policy StringEquals condition: T Cloud accepts the federation only when the ID token’s sub claim matches one of the listed values. Add more sub patterns to allow additional branches, tags, or repos.
Step 3: Create an agency to assume
Create an agency (IAM → Agencies → Create Agency) with the following:
- Agency type: Account (cloud service of another T Cloud account does not apply here).
- Delegated party: select the OpenID Connect identity provider you created in Step 1.
- Permissions: attach the policies the CI job needs (e.g.
OBS Administratorscoped to the workload OBS bucket, orCCE FullAccessscoped to the workload project).
Note the agency name and the project where you scope it.
Step 4: Use the credentials in a workflow
The runner fetches an ID token, exchanges it for a T Cloud token at /v3/auth/tokens using the id_token auth method, and then optionally assumes the agency:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: fremforge
steps:
- name: Get fremforge OIDC token
run: |
TOKEN=$(curl -sS \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://frem.sh" \
| jq -r .value)
echo "FREMFORGE_OIDC_TOKEN=$TOKEN" >> $GITHUB_ENV
- name: Exchange for T Cloud token
run: |
RESP=$(curl -sS -i -X POST \
"https://iam.eu-de.otc.t-systems.com/v3/auth/tokens" \
-H "Content-Type: application/json" \
-d "{
\"auth\": {
\"identity\": {
\"methods\": [\"id_token\"],
\"id_token\": {\"id\": \"$FREMFORGE_OIDC_TOKEN\"}
},
\"scope\": {
\"project\": {\"name\": \"eu-de_<your-project>\"}
}
}
}")
# T Cloud returns the token in the X-Subject-Token response header
TCLOUD_TOKEN=$(echo "$RESP" | grep -i '^x-subject-token:' | awk '{print $2}' | tr -d '\r')
echo "::add-mask::$TCLOUD_TOKEN"
echo "OS_AUTH_TOKEN=$TCLOUD_TOKEN" >> $GITHUB_ENVAlternatively, use the T Cloud CLI (otc) or OpenTofu with the opentelekomcloud provider. Both accept OS_AUTH_TOKEN from the environment without further configuration.
Notes on T Cloud specifics
- T Cloud does not auto-rotate JWKS. You paste the signing key once at provider creation; if fremforge rotates the OIDC signing key (announced on the trust page), re-paste the new JWKS JSON. AWS, Azure, and GCP all auto-fetch from the discovery document; T Cloud requires the explicit paste.
- The exchanged token is in the
X-Subject-Tokenresponse header, not in the response body, this is OpenStack-Keystone behaviour. The body contains the token’s metadata (catalog, project, expiry). - Agency scoping is enforced both by the mapping (
subclaim must match) and by the permissions attached to the agency. Use both layers, the mapping prevents federation to the wrong workflow, the permissions prevent the federated workflow from doing more than its job needs.
AWS IAM
Step 1: Create an OIDC identity provider
In the AWS console, go to IAM → Identity providers → Add provider.
- Provider type: OpenID Connect
- Provider URL:
https://frem.sh/_app/runner/oidc(single global issuer; per-tenant filtering is done in the trust-policy condition, not at the provider URL) - Audience:
https://frem.sh
AWS fetches the JWKS from the discovery document to verify tokens.
Step 2: Create an IAM role with a web identity trust policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<account-id>:oidc-provider/frem.sh/_app/runner/oidc"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"frem.sh/_app/runner/oidc:aud": "https://frem.sh",
"frem.sh/_app/runner/oidc:repository_owner": "<your-org>",
"frem.sh/_app/runner/oidc:sub": "repo:<your-org>/<your-repo>:ref:refs/heads/main"
}
}
}
]
}Attach the permissions the CI job needs to this role (e.g. AmazonS3FullAccess, AmazonECRPowerUser).
Step 3: Use in a workflow
Use the official aws-actions/configure-aws-credentials action:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: fremforge
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<account-id>:role/<role-name>
aws-region: eu-central-1
audience: https://frem.sh
- name: Deploy
run: aws s3 sync ./dist s3://my-bucket/The action handles the token fetch, sts:AssumeRoleWithWebIdentity call, and credential injection automatically.
Azure (Entra Workload Identity)
Step 1: Create federated credentials on an app registration
- In the Azure portal, go to Entra ID → App registrations →
<your app>→ Certificates & secrets → Federated credentials. - Click Add credential.
- Scenario: Other issuer.
- Fill in:
- Issuer:
https://frem.sh/_app/runner/oidc(single global issuer; tenant isolation is enforced by the Subject identifier below) - Subject identifier:
repo:<your-org>/<your-repo>:ref:refs/heads/main - Audience:
https://frem.sh
- Issuer:
- Save.
Step 2: Use in a workflow
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: fremforge
steps:
- uses: actions/checkout@v4
- name: Get OIDC token
run: |
TOKEN=$(curl -sS \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://frem.sh" \
| jq -r .value)
echo "OIDC_TOKEN=$TOKEN" >> $GITHUB_ENV
- name: Azure login via federated token
run: |
az login \
--service-principal \
--tenant <tenant-id> \
--client-id <client-id> \
--federated-token "$OIDC_TOKEN"
- name: Deploy
run: az webapp deploy --resource-group my-rg --name my-app --src-path ./distThe app registration’s client ID and your Entra tenant ID are not secrets. They can be stored as plain workflow variables. Only the OIDC token is sensitive, and it expires after the job completes.
GCP Workload Identity
Step 1: Create a Workload Identity pool and OIDC provider
# Create the pool
gcloud iam workload-identity-pools create fremforge-pool \
--location global \
--display-name "fremforge CI"
# Create the OIDC provider inside the pool
gcloud iam workload-identity-pools providers create-oidc fremforge-provider \
--location global \
--workload-identity-pool fremforge-pool \
--issuer-uri "https://frem.sh/_app/runner/oidc" \
--allowed-audiences "https://frem.sh" \
--attribute-mapping "google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner"Step 2: Grant service account impersonation
# Allow the pool to impersonate a service account, scoped by sub claim
gcloud iam service-accounts add-iam-policy-binding <sa>@<project>.iam.gserviceaccount.com \
--role roles/iam.workloadIdentityUser \
--member "principalSet://iam.googleapis.com/projects/<project-number>/locations/global/workloadIdentityPools/fremforge-pool/attribute.sub/repo:<your-org>/<your-repo>:ref:refs/heads/main"Step 3: Use in a workflow
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: fremforge
steps:
- uses: actions/checkout@v4
- name: Authenticate to GCP via OIDC
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/<project-number>/locations/global/workloadIdentityPools/fremforge-pool/providers/fremforge-provider
service_account: <sa>@<project>.iam.gserviceaccount.com
audience: https://frem.sh
- name: Deploy
run: gcloud run deploy my-service --image gcr.io/my-project/my-imageThe google-github-actions/auth action works on fremforge runners. It uses the same ACTIONS_ID_TOKEN_REQUEST_TOKEN / ACTIONS_ID_TOKEN_REQUEST_URL mechanism.
Troubleshooting
Audience mismatch.
The aud claim in the token must match what the provider expects. If the trust policy or provider is configured for a different audience (e.g. sts.amazonaws.com instead of https://frem.sh), the exchange fails with an audience validation error. Set audience: https://frem.sh explicitly in the provider config and in the token request (use &audience=https://frem.sh in the curl URL).
sub claim does not match the trust policy condition.
The sub claim format is exact: repo:<org>/<repo>:ref:refs/heads/<branch>. Common mismatches:
- Branch name typo (
mainvsmaster). - The trust policy uses a tag ref (
refs/tags/v1.0) but the job runs on a branch. - The policy uses
refs/heads/mainbut the job runs on a PR branch (refs/pull/42/head). UseStringLikewith a wildcard if you need to allow PRs:"repo:acme/api:ref:refs/pull/*".
Token expired, jobs longer than 1 hour.
The OIDC token has a 1-hour TTL. Jobs that run for more than 1 hour receive a fresh token each time they call the token endpoint. Each step that calls ACTIONS_ID_TOKEN_REQUEST_URL gets a new token. If you cache the token across steps spanning more than 1 hour, it expires. Fetch a fresh token at each step that needs credentials.
id-token: write permission missing.
If permissions: is not set in the workflow or job, the runner uses the default read-only permission set, which does not include id-token: write. The ACTIONS_ID_TOKEN_REQUEST_TOKEN variable will be absent and the token request returns 403. Add id-token: write to the permissions: block at the job level (or workflow level if all jobs need it).
Per-tenant federation overrides
By default the issuer signs tokens with the platform claim shape (sub = repo:<org>/<repo>:ref:<ref>, no aud restriction). Two knobs let you pin tighter contracts on a per-tenant basis:
allowed_audiences, a hard allowlist ofaudvalues the issuer will sign. Any mint request withaudoutside the list is rejected before the JWT is created. Useful when you want to mint tokens for AWS and T Cloud trust policies but block any accidentally-broader audience claim. Empty list = no restriction (platform default).sub_claim_template, a Mustache-shaped template rendered against four fixtures ({{tenant}},{{repo}},{{branch}},{{ref_type}}) at token mint time. Useful when your existing GitHub Actions / Bitbucket Pipelines trust policy expects a differentsubshape than the fremforge default.
Reading the current overrides
curl -H "Authorization: Bearer $FREMFORGE_PAT" \
https://frem.sh/_app/api/v1/orgs/acme/runners/federation
# {
# "issuer": "https://frem.sh/runner/oidc",
# "jwks_uri": "https://frem.sh/runner/oidc/.well-known/jwks.json",
# "tenant_org_slug": "acme",
# "allowed_audiences": [],
# "sub_claim_template": null
# }Pinning allowed_audiences
curl -X PUT -H "Authorization: Bearer $FREMFORGE_PAT" \
-H "Content-Type: application/json" \
-d '{
"allowed_audiences": [
"sts.amazonaws.com",
"sts.eu-west-1.amazonaws.com"
]
}' \
https://frem.sh/_app/api/v1/orgs/acme/runners/federationAfter this, any runner-controller attempt to mint a token with aud not in the list returns RunnerFederationAudienceRejectedError and the job fails closed. PAT scope required: runners:write.
Customising sub_claim_template
curl -X PUT -H "Authorization: Bearer $FREMFORGE_PAT" \
-H "Content-Type: application/json" \
-d '{
"sub_claim_template": "tenant:{{tenant}}:repo:{{repo}}:branch:{{branch}}"
}' \
https://frem.sh/_app/api/v1/orgs/acme/runners/federationThe four supported substitutions are:
| Token | Resolves to | Example |
|---|---|---|
{{tenant}} | The org slug | acme |
{{repo}} | <org>/<repo> | acme/api |
{{branch}} | Branch name (only when ref_type=branch; empty for tags) | main |
{{ref_type}} | branch or tag | branch |
Unknown placeholders are left literal in the output, for example, {{nope}} stays as {{nope}} in the rendered sub. This is intentional: a misconfigured template fails loudly at the trust-policy match step rather than silently emitting an empty string.
Resetting to defaults
Send an empty allowed_audiences and null sub_claim_template:
curl -X PUT -H "Authorization: Bearer $FREMFORGE_PAT" \
-H "Content-Type: application/json" \
-d '{ "allowed_audiences": [], "sub_claim_template": null }' \
https://frem.sh/_app/api/v1/orgs/acme/runners/federationAll updates are recorded in the tenant audit log under runner_federation.updated. The body snapshot omits the full sub_claim_template but records whether one was set; full audit trace is in the LTS log for audit_event_emitted.
Scaleway IAM External App
Scaleway is a French-headquartered EU-sovereign cloud with data residency in FR / NL / PL. Their IAM External Apps primitive (GA October 2024) accepts an upstream OIDC IdP and lets you bind workload principals via JWT claims, the same federation shape AWS / GCP / Azure offer.
Step 1: Create an IAM External App
In the Scaleway console: IAM → External Apps → Add an External App. Configure:
- Issuer URL, the fremforge issuer URL from the wizard (e.g.
https://frem.sh/_app/runner/oidc). - JWKS URI, the fremforge JWKS URI.
- Subject mapping, bind on
sub. Constrain to one repo if possible (repo:<your-slug>/<repo>:*). - Audience, your Scaleway organisation URN (
scw:org:<org-id>).
Step 2: Attach a Policy
Attach a Scaleway Policy to the External App granting the actions your CI needs. Scope to a single Project for least-privilege.
Step 3: Use in a workflow
- name: Get Scaleway STS token
id: scw-token
uses: frem.sh/mirrors/actions-oidc@v1
with:
audience: scw:org:<org-id>
- name: Deploy
env:
SCW_ACCESS_KEY: ${{ steps.scw-token.outputs.access_key }}
SCW_SECRET_KEY: ${{ steps.scw-token.outputs.secret_key }}
run: ./deploy.shThe token-exchange endpoint shape is documented in the Scaleway IAM docs, the External App primitive emits short-lived STS-style keys keyed on the OIDC subject.
Generic OIDC broker (Authentik, Keycloak)
For setups where the cloud doesn’t speak workload OIDC directly (legacy SAML-only enterprise targets, on-prem deployers, multi-cloud trust maps maintained centrally), use a broker: Authentik, Keycloak, or any OIDC-RP-with-downstream-RP setup.
When to use a broker
- You already run Authentik / Keycloak for human SSO and want CI workloads under the same federation surface.
- You want one trust-policy registration to manage, not N per-cloud. Adding a new cloud is then “add a new Authentik provider,” not “redo the trust policy in each cloud.”
- Your target is SAML-only (some on-prem regulated systems). The broker bridges OIDC → SAML.
- You’re targeting a cloud without first-class workload-OIDC (Stackit, OVH, Hetzner), federate via the broker rather than wait for first-class support.
Step 1: Add fremforge as an upstream source
In Authentik: Directory → Federation → OAuth/OIDC Source. Set:
- OIDC Provider, issuer URL from the wizard.
- Audience, leave empty or set per your downstream provider.
- Scopes,
openidis sufficient; fremforge tokens carry no profile/email scopes.
The Keycloak equivalent is Identity Providers → OpenID Connect v1.0 with Use JWKS URL enabled.
Step 2: Map claims to local groups / roles
In Authentik: Customisation → Property Mappings → OIDC. Map the runner JWT’s sub (shape repo:<slug>/<repo>:ref:refs/heads/<branch>) onto local groups using a small Python expression:
# Example: anyone deploying main → 'prod-deploy' group
import re
if re.match(r'^repo:[^/]+/[^:]+:ref:refs/heads/main$', token.get('sub', '')):
return ['prod-deploy']
return []Step 3: Use the broker as the downstream-cloud issuer
Your cloud’s trust policy then matches on a token signed by Authentik, not by fremforge directly. Audience = the Authentik Provider’s client_id. Exchange the fremforge JWT for an Authentik-signed JWT via Authentik’s /application/o/token/ endpoint:
- name: Get fremforge runner OIDC token
id: ff-token
uses: frem.sh/mirrors/actions-oidc@v1
with: { audience: <authentik-provider-client-id> }
- name: Exchange for Authentik token
id: ak-token
run: |
curl -fsS -X POST https://idp.acme.com/application/o/token/ \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token=${{ steps.ff-token.outputs.token }} \
-d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
-d audience=<downstream-rp> \
| jq -r .access_token > /tmp/ak-token
- name: Deploy
env:
BROKER_TOKEN: $(cat /tmp/ak-token)
run: ./deploy.shTrade-off
The broker is one extra hop in the runner→cloud path (more latency, one more place to debug). It pays for itself if you have ≥3 clouds, regulated SAML targets, or already run a broker for human SSO. For single-cloud setups, federate directly to the cloud (tabs above).
Cross-references
- CI runners, hosted and BYO runner reference
- Secrets, storing and scoping long-lived credentials
- Security, runner isolation model and supply-chain security