Federate CI runners (GitHub Actions, GitLab CI) to hort-server

This guide is for operators who want their CI/CD pipelines to push artifacts to hort without storing a long-lived PAT in the CI provider's secret store. Both GitHub Actions and GitLab CI expose short-lived OIDC JWTs to job runners; hort-server treats both as generic OIDC issuers and exchanges their tokens for an hort-server bearer at POST /api/v1/auth/exchange.

For the design rationale see ADR 0018 and the federation entries in docs/auth-catalog.md.


1. What this gives you

Without federation, a typical CI pipeline keeps an hort-server PAT in secrets.HORT_TOKEN (GitHub) or a masked CI/CD variable (GitLab). That credential was long-lived, scoped at the user level rather than per-workflow, opaque to audit, and rotated only when someone remembered.

Federation flips all four: the CI provider mints a fresh JWT per job, trust is declared per (repository, environment, ref, …) claim shape, the TokenIssued event records source_issuer/source_sub/source_jti, and the CI provider rotates its own signing keys with hort-server picking up the new JWKS automatically.

Existing PATs continue to work — adoption is per-workflow at the operator's pace.


2. Common ground

Both providers issue short-lived OIDC JWTs that hort-server treats identically. The provider mints a JWT with provider-specific claims; the job posts it to /api/v1/auth/exchange with subject_token_type=urn:ietf:params:oauth:token-type:jwt; the server validates signature + audience + standard claims, matches the claims against ServiceAccount.federatedIdentities[].claims, and returns a short-lived bearer.

What differs: OidcIssuer.spec.issuerUrl (GHA has one global issuer; GitLab CI's is per-instance) and the federatedIdentities[].claims shape (GHA uses repository, environment, workflow, actor; GitLab uses project_path, ref, ref_protected, user_email). The exchange flow itself is uniform — one hort-server deployment serves both providers concurrently.


2a. The minimum-viable trust boundary (read before you write claims)

⚠️ federatedIdentities[].claims must carry at least one subject-identifying claim — sub, repository, or project_path. Every other claim (aud, ref, environment, workflow, and every GitHub/GitLab-specific claim not in that list) refines a subject; none of them identify one on their own. GitHub Actions is a shared, multi-tenant issuer — token.actions.githubusercontent.com signs for every repository on GitHub — so an aud-only (or ref-only, environment-only, workflow-only) claims map is rejected at apply time: aud is caller-chosen by whoever requests the token, and on its own it constrains nothing about which repository or workflow may assume the identity. Every example in this guide carries repository or project_path, which satisfies this rule on its own — the rest of this section is about a SEPARATE, narrower risk: repository / project_path alone is under-constrained (see below), not rejected.

**⚠️ Binding a federated ServiceAccount on repository / project_path alone is under-constrained — any pipeline or branch in that repo can assume it.** A repository/project claim says which repo the token came from. It says nothing about which workflow, which branch, or which environment minted it. Any job a contributor can run in that repo — a fork PR's CI, an unprotected feature branch, a hand-pushed workflow_dispatch — mints a JWT carrying the same repository/project_path. An FI constrained on that claim alone is therefore assumable by every job in the repo, not just the release pipeline you intended. repository/project_path is a necessary discriminator, never a sufficient trust boundary. Remediation — apply both: 1. Pin aud to the exact value in your OidcIssuer.spec.audiences list (set it as the audience= JWT-fetch parameter on GHA / the id_tokens[].aud on GitLab, and match it in the FI). This binds the token to this relying party so a token minted for a different aud cannot assume the SA. AND 2. Add at least one discriminating claim alongside the repository/project claim: - GitHub: one (or more) of ref / environment / workflow / job_workflow_ref (job_workflow_ref is the strongest — see §8 "Pin job_workflow_ref for release pipelines"). - GitLab: ci_config_ref_uri, or ref plus ref_protected: 'true' (and environment when the job declares one). repository + aud + ref (or environment/workflow/ job_workflow_ref) is the floor. The §3b/§4b examples below and the §8 release-pipeline guidance all satisfy it; do not declare an FI that does not. This is the same condition the binary warns you about. At gitops apply, hort-server runs an under-constrained federated-identity check (an apply-time WARN — fail-loud, not silent). An FI that names a repository/project_path claim without any of ref / environment / workflow / aud emits this warn! (apply still succeeds — a repo-only policy is a footgun, not a schema error, and a single-tenant repo may legitimately accept the residual risk): ``text WARN gitops apply: under-constrained federatedIdentities — ServiceAccount <name> federatedIdentities[<idx>] (issuer <issuer>) constrains only a repository/project claim without a discriminating ref/environment/workflow — any workflow in that repo can assume this identity. Add a discriminating claim (e.g. ref, environment, workflow). ` (structured fields: service_account, federated_identity_index, issuer). If you see this WARN in kubectl logs … deploy/hort-server` after an apply, this section is the fix. It is a warning, not a hard rejection — so it will not block your apply; it is on you to narrow the FI before the SA is used.


2b. Server enablement — federation works with or without an interactive IdP

CI federation is served by the POST /api/v1/auth/exchange endpoint, which is off by default. Enabling it needs three server flags. The exchange mints a short-lived hort_* native token, so native tokens must be on, and the validator needs a signing key:

HORT_TOKEN_EXCHANGE_ENABLED=true     # mount /api/v1/auth/exchange
HORT_NATIVE_TOKENS_ENABLED=true      # mint+validate the hort_* token it returns
HORT_OCI_TOKEN_SIGNING_KEY(_FILE)=…  # the signing key (resolved active key)

Federation validates the CI JWT against your gitops OidcIssuer rows (declared below) — not against an interactive identity provider. So there are two deployment shapes:

ModeHORT_AUTH_PROVIDERAlso requiredWhat you get
With an interactive IdP (Keycloak, etc.)oidcHORT_OIDC_ISSUER_URL, HORT_OIDC_CLI_CLIENT_ID, HORT_PUBLIC_BASE_URLCI federation and the hort-cli device-flow login (/.well-known/hort-client-config is served).
Federation-only / no Keycloakdisabled(none of the OIDC vars)CI federation only. Human users authenticate with PATs (hort_pat_*). The /.well-known/hort-client-config doc is not served, and the interactive access_token branch of /exchange returns 400 invalid_request pointing callers at the jwt branch.

The federation security guarantees are identical in both modes — the three ship-gate guardrails (JWT-replay seen-set, aud→ServiceAccount binding, empty-claims fail-closed) live in the exchange handler and do not depend on HORT_AUTH_PROVIDER. The public-supply-chain deployment (registry.hort.rs) runs federation-only: PATs for developers, federation for CI, no Keycloak.

If you set HORT_TOKEN_EXCHANGE_ENABLED=true with HORT_NATIVE_TOKENS_ENABLED unset/false, boot fails closed (TokenExchangeRequiresNativeTokens) — the server refuses to mint tokens it could not then validate.


3. GitHub Actions

3a. Declare the OidcIssuer

apiVersion: project-hort.de/v1
kind: OidcIssuer
metadata:
  name: github-actions
spec:
  issuerUrl: https://token.actions.githubusercontent.com
  audiences: [hort-server]
  jwksRefreshInterval: 1h
  allowedAlgorithms: [RS256]

issuerUrl is the same for every GitHub-hosted runner globally; self-hosted runners hosted on github.com use the same issuer. GHE Server runs its own issuer at the GHE instance's URL — adjust accordingly.

3b. Declare the ServiceAccount

The most common claim discriminator pair is (repository, environment). Use environments (the GitHub feature, not arbitrary strings) to bind production-push capability to the production environment, requiring its approval rules:

The envelope binds the identity; the PermissionGrants beside it are the SA's authority (ADR 0044) — the exchanged token's cap snapshots them at exchange time:

apiVersion: project-hort.de/v1
kind: ServiceAccount
metadata:
  name: gha-myorg-myrepo-prod-pypi
spec:
  federatedIdentities:
    - issuer: github-actions
      claims:
        repository: my-org/my-repo
        environment: production
---
apiVersion: project-hort.de/v1
kind: PermissionGrant
metadata:
  name: gha-myorg-myrepo-prod-pypi-write
spec:
  subject:
    kind: serviceAccount
    name: gha-myorg-myrepo-prod-pypi
  permission: write
  repository: pypi-internal
---
apiVersion: project-hort.de/v1
kind: PermissionGrant
metadata:
  name: gha-myorg-myrepo-prod-pypi-read
spec:
  subject:
    kind: serviceAccount
    name: gha-myorg-myrepo-prod-pypi
  permission: read
  repository: pypi-internal

Permissions are flat — write does not imply read; a publisher that reads back what it pushed needs both grants.

Other useful claim discriminators when environment is not in play: ref: refs/heads/main restricts to main pushes; workflow: Release restricts to the Release workflow file; job_workflow_ref: my-org/my-repo/.github/workflows/release.yml@refs/heads/main pins the workflow's full origin including ref (best for release-grade pipelines). actor is a poor discriminator — workflow_dispatch runs carry the dispatcher's username, making per-user matching brittle.

Prefer a distinct aud over a synthetic GitHub Environment when the only reason for the environment is to discriminate SAs (issue #82). A GitHub Environment gates approvals AND mints a deployment record on every run that references it — fine when you want the gate, wasteful noise when you don't. hort's own CI originally declared a non-gating environment: ci on every workflow job purely so its OIDC token would carry an environment claim distinguishing it from the release pipeline's environment: release (issue #64) — but that meant every CI run, not just releases, stamped a GitHub deployment record for a gate that never actually gated anything. The fix: request a distinct aud per SA class instead (core.getIDToken('<class-audience>') on GHA, id_tokens.<name>.aud on GitLab) and add each audience to the issuer's audiences allow-list. repository + aud alone is already a sufficient discriminator when no other SA in the same repo requests the same audience — no environment, no deployment record. Reserve environment: for claims that need the actual GitHub Environment features (approval gates, branch/tag protection) — gha-release's environment: release stays for exactly that reason.

3c. The workflow file

End-to-end example pushing a wheel to a Hosted PyPI repository:

name: Release wheel
on:
  push:
    branches: [main]

permissions:
  id-token: write          # required for the OIDC token
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: production    # gates token claims as well as approvals
    steps:
      - uses: actions/checkout@v4

      - name: Build wheel
        run: |
          pip install build
          python -m build --wheel

      - name: Exchange OIDC token for hort-server bearer
        id: hort-auth
        env:
          HORT_BASE_URL: https://hort.example.com
        run: |
          JWT=$(curl -sS \
            -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
            "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=hort-server" \
            | jq -r .value)
          RESPONSE=$(curl -sS -X POST \
            "${HORT_BASE_URL}/api/v1/auth/exchange" \
            -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
            -d "subject_token=${JWT}" \
            -d "subject_token_type=urn:ietf:params:oauth:token-type:jwt")
          HORT_TOKEN=$(echo "${RESPONSE}" | jq -r .access_token)
          echo "::add-mask::${HORT_TOKEN}"
          echo "hort_token=${HORT_TOKEN}" >> "$GITHUB_OUTPUT"

      - name: Push wheel via twine
        env:
          TWINE_USERNAME: __token__
          TWINE_PASSWORD: ${{ steps.hort-auth.outputs.hort_token }}
        run: |
          pip install twine
          twine upload \
            --repository-url https://hort.example.com/pypi/pypi-internal/ \
            dist/*.whl

Notes:

For other client tools, replace the twine call:


4. GitLab CI

4a. Declare the OidcIssuer

The issuerUrl is the GitLab instance's base URL. For gitlab.com:

apiVersion: project-hort.de/v1
kind: OidcIssuer
metadata:
  name: gitlab-com
spec:
  issuerUrl: https://gitlab.com
  audiences: [hort-server]
  jwksRefreshInterval: 1h
  allowedAlgorithms: [RS256]

For a self-hosted instance, substitute the instance hostname (https://gitlab.example.com). GitLab serves JWKS at /oauth/discovery/keys; hort-server discovers this from /.well-known/openid-configuration at the issuer URL.

4b. Declare the ServiceAccount

GitLab's claim shape is project-centric. Match on project_path

ref_protected: 'true':

apiVersion: project-hort.de/v1
kind: ServiceAccount
metadata:
  name: glci-myorg-myrepo-pypi
spec:
  federatedIdentities:
    - issuer: gitlab-com
      claims:
        project_path: my-org/my-repo
        ref: main
        ref_protected: 'true'
---
apiVersion: project-hort.de/v1
kind: PermissionGrant
metadata:
  name: glci-myorg-myrepo-pypi-write
spec:
  subject:
    kind: serviceAccount
    name: glci-myorg-myrepo-pypi
  permission: write
  repository: pypi-internal

Note 'true' is a string — JWT claim values are typically strings even when they represent booleans. The exact-match comparison is on string equality.

Other useful claims:

4c. The .gitlab-ci.yml

stages: [publish]

publish:wheel:
  stage: publish
  image: python:3.12
  id_tokens:
    HORT_JWT:
      aud: hort-server
  variables:
    HORT_BASE_URL: https://hort.example.com
  rules:
    - if: '$CI_COMMIT_REF_NAME == "main"'
  script:
    - pip install build twine
    - python -m build --wheel
    - |
      RESPONSE=$(curl -sS -X POST \
        "${HORT_BASE_URL}/api/v1/auth/exchange" \
        -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
        -d "subject_token=${HORT_JWT}" \
        -d "subject_token_type=urn:ietf:params:oauth:token-type:jwt")
      export HORT_TOKEN=$(echo "${RESPONSE}" | jq -r .access_token)
    - |
      twine upload \
        --repository-url "${HORT_BASE_URL}/pypi/pypi-internal/" \
        --username __token__ \
        --password "${HORT_TOKEN}" \
        dist/*.whl

Notes:


5. Claim shape reference

Quick reference for the most useful claims, per provider.

GitHub Actions

ClaimMeaningWhen to match
repositoryowner/repoPer-repo isolation. Almost always required.
repository_ownerownerOrg-wide identity (rare; prefer per-repo).
environmentEnvironment nameGates on the GitHub Environment + its approval rules.
refFull ref (refs/heads/main, refs/tags/v1.0)Restrict to specific branches or tags.
actorTriggering user's loginGenerally avoid — brittle on workflow_dispatch.
workflowWorkflow file's name:Workflow-specific identity.
job_workflow_refFull workflow-file path + refStrongest pinning for release pipelines.

GitLab CI

ClaimMeaningWhen to match
project_pathgroup/subgroup/projectPer-project isolation. Almost always required.
refBranch or tag name (no refs/heads/ prefix — just main)Restrict to specific branches.
ref_protected'true' / 'false' (string)Restrict to protected-branch pushes.
ref_type'branch' / 'tag'Distinguish tag pushes from branch pushes.
environmentGitLab Environment nameGates on environment + its protection rules.
user_emailTriggering user's emailTie identity to a deploy-bot account.
iidInternal pipeline iidGenerally avoid — high-cardinality.

Both providers publish authoritative claim references at their own docs; GitHub OIDC claim reference and GitLab OIDC ID token claims.


6. Verify it works

Run the workflow / pipeline once. On success:

kubectl logs -n hort-server deploy/hort-server | grep TokenIssued | tail -1

Expect to see source_issuer = "github-actions" (or "gitlab-com"), source_sub matching the JWT's sub claim, and source_jti populated. The metric hort_token_exchange_total{kind="federated_jwt", result="success"} ticks on every successful exchange.

A negative check: drop a wrong claim into the ServiceAccount (environment: nonexistent), re-run, and confirm a 403 with reason="no_sa_match" in hort-server's tracing::info!. The CI job should fail at the curl step with a non-success HTTP status.


7. Troubleshooting

The deny taxonomy is the same as the k8s recipe; see federate-k8s-workload-identity.md §8 for the table. Provider-specific gotchas:

unknown_issuer (GitHub)

The iss claim of the GHA JWT is https://token.actions.githubusercontent.com for every github.com-hosted job, regardless of the repository. If you see unknown_issuer, the OidcIssuer.spec.issuerUrl is the wrong string — most often a trailing slash or http:// typo.

unknown_issuer (GitLab self-hosted)

The iss claim is the GitLab instance's URL exactly as the operator configured it (https://gitlab.example.com, no trailing slash). Mismatches against OidcIssuer.spec.issuerUrl produce unknown_issuer. Cross-check via curl https://gitlab.example.com/.well-known/openid-configuration | jq .issuer.

aud_mismatch

GHA: the audience= query parameter on ${ACTIONS_ID_TOKEN_REQUEST_URL} is wrong. GitLab: the aud: under the id_tokens: block is wrong. Both must appear in the OidcIssuer.spec.audiences list verbatim.

no_sa_match

The JWT validated but no ServiceAccount.federatedIdentities[] matched. Decode the JWT (jq -R 'split(".") | .[1] | @base64d | fromjson') and compare its claims to your envelope character by character. GHA's repository is owner/repo; GitLab's project_path includes any subgroups (group/subgroup/repo).

multiple_sa_match

Two envelopes' claim sets are both subsets of the same JWT's claims. Add a discriminator to one envelope (e.g. tighten environment or ref) so the match is unambiguous. hort-server logs the SA-name candidates at INFO; check the log to see which envelopes are conflicting. Pinning aud per SA class (see the callout in §3b above) is usually the least disruptive fix — it adds a discriminator without touching ref/environment/workflow, and (unlike a GitHub Environment) never mints a deployment record.

signature_invalid

Either the JWKS cache is stale (lower jwksRefreshInterval to 5m and re-run), or the JWT was signed by a different issuer than its iss claim names. The latter is rare in practice but indicates a CI provider misconfiguration; open a support ticket with them.


8. Security considerations

Exact-match claim selection is safer than regex. Regex matching is deliberately excluded: every claim-value comparison is byte-equality. Regex extraction (e.g. repository: ^my-org/.+) would let one envelope cover an entire org, which becomes a privilege-creep liability when the org grows past the original operator's mental model. The exact-match constraint forces every covered repository to be explicitly named — discoverable in the gitops history, reviewable, and revocable line by line.

Narrow each ServiceAccount to one workflow scope. One envelope per (repository, environment) or (project_path, ref) tuple. Reusing an envelope across multiple workflows collapses the audit trail and makes per-workflow revocation impossible — the TokenIssued event records source_sub and source_jti, but the hort-server identity it bound to is the same across all of them.

Never re-use a federated identity for human use. A ServiceAccount's backing user has is_service_account = true and is invisible to /users/me flows. Don't try to share it with an interactive operator workflow — that defeats both the short-lifetime guarantee and the per-workflow audit attribution.

Pin job_workflow_ref for release pipelines. For pipelines that mint production artifacts, match on job_workflow_ref (GHA) or the combination of project_path + ref + ref_protected: 'true' (GitLab). These are the strongest discriminators the providers offer and they prevent a forked PR or an unprotected-branch push from forging a release-grade token.


9. See also