Workload identity federation — call FlexGalaxy.AI APIs from CI without long-lived secrets

⚠️ Stale — describes a design that was not adopted in neo; pending rewrite.

The GitHub Actions / GitLab CI federation flow documented below is not the mechanism currently wired in the running deployment. The live federation trust-root accepts OIDC tokens from FlexGalaxy.AI’s own in-cluster workloads, not from public GitHub or GitLab issuers. If you need to call FlexGalaxy.AI APIs from CI without long-lived secrets, contact FlexGalaxy.AI support for the current integration path before relying on the steps below.

If your CI/CD pipeline calls FlexGalaxy.AI APIs or otherwise acts as a non-human principal, do not ship a long-lived API key in GITHUB_SECRETS or GitLab CI variables. Use workload identity federation: your workload presents its IdP-issued OIDC token to DotID, DotID validates it, and DotID returns a short-lived (15 min) access token scoped to one FlexGalaxy.AI service.

This page collects everything you need at the call site.

TL;DR. Your GitHub Actions or GitLab CI job requests an OIDC token with audience dotid-token-exchange, posts it to https://iam.flexgalaxy.ai/oauth2/token as an RFC 8693 token exchange, receives a 15-minute access token, and presents that to the target FlexGalaxy.AI service as Authorization: Bearer <token>. No refresh tokens. No long-lived secrets in your CI.

Prerequisites — what your FlexGalaxy.AI account admin must set up first

This flow requires two pieces of one-time setup inside your FlexGalaxy.AI account. Forward this list to your Account root or an authorized account admin:

  1. Create a workload identity in the account that owns the target service. The account admin opens {{ dotid_console_prod_account }}Workload identitiesNew and gives it a descriptive name (e.g. bazaar-publish-syrius).

  2. Bind the federated subject to that workload identity. For GitHub Actions, the bound subject is the (issuer, sub) tuple from the GitHub OIDC token — typically repo:<owner>/<repo>:ref:refs/heads/<branch> (or :environment:<env>, :pull_request, etc.) under issuer https://token.actions.githubusercontent.com. For GitLab CI, the bound subject is project_path:<group>/<project>:ref_type:branch:ref:<branch> under issuer https://gitlab.com (or your self-hosted GitLab URL). The account admin sends a POST /identity/v1/accounts/{accountId}/workload-identities/federated with:

    {
      "workload_identity_id": "<id from step 1>",
      "issuer": "https://token.actions.githubusercontent.com",
      "subject": "repo:syrius/bazaar:ref:refs/heads/main"
    }
    
  3. Author the IAM policy that allows the workload identity to exchange for the target service. The action is iam:ExchangeToken; the resource is arn:fg:dotid::service/<target-service>:

    {
      "Effect": "Allow",
      "Action": "iam:ExchangeToken",
      "Resource": "arn:fg:dotid::service/bazaar-publisher"
    }
    

    Attach to the workload identity (or to a group it belongs to) through the standard IAM policy UI.

Once those three steps land, you can run the rest of this guide from your CI without further account-admin involvement.

GitHub Actions — sample workflow

name: publish-to-bazaar
on:
  push:
    branches: [main]

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

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Request OIDC token from GitHub
        id: oidc
        run: |
          OIDC_TOKEN=$(curl -sLS \
            -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=dotid-token-exchange" \
            | jq -r '.value')
          echo "oidc_token=$OIDC_TOKEN" >> "$GITHUB_OUTPUT"

      - name: Exchange for a DotID access token
        id: exchange
        run: |
          RESPONSE=$(curl -sS -X POST https://iam.flexgalaxy.ai/oauth2/token \
            -H "Content-Type: application/x-www-form-urlencoded" \
            --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
            --data-urlencode "subject_token=${{ steps.oidc.outputs.oidc_token }}" \
            --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
            --data-urlencode "audience=bazaar-publisher")
          ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token')
          echo "::add-mask::$ACCESS_TOKEN"
          echo "access_token=$ACCESS_TOKEN" >> "$GITHUB_OUTPUT"

      - name: Call the target FlexGalaxy.AI service
        env:
          SERVICE_TOKEN: ${{ steps.exchange.outputs.access_token }}
        run: |
          # The target path and payload are defined by whichever FlexGalaxy.AI
          # service you call. Substitute <your-service> and the real route.
          curl -sS -X POST "https://api.flexgalaxy.ai/<your-service>/v1/..." \
            -H "Authorization: Bearer $SERVICE_TOKEN" \
            -F "artifact=@./build/my-artifact-1.0.0.tar.gz"

Critical points:

  • permissions: id-token: write — without this, ACTIONS_ID_TOKEN_REQUEST_TOKEN is not exported to the job and step 1 fails silently.

  • &audience=dotid-token-exchangemust be this literal string. This is the anti-confused-deputy audience pin. A token with any other audience is rejected.

  • ::add-mask:: on the access token — prevents accidental log leakage. The token is short-lived, but masking is cheap insurance.

  • The exchanged token’s aud is bazaar-publisher (the value of the request audience parameter). Each downstream service validates its own audience.

GitLab CI — sample job

publish-to-bazaar:
  image: alpine:3.20
  before_script:
    - apk add --no-cache curl jq
  id_tokens:
    DOTID_TOKEN:
      aud: dotid-token-exchange     # MUST be this literal string
  script:
    - |
      RESPONSE=$(curl -sS -X POST https://iam.flexgalaxy.ai/oauth2/token \
        -H "Content-Type: application/x-www-form-urlencoded" \
        --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
        --data-urlencode "subject_token=$DOTID_TOKEN" \
        --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
        --data-urlencode "audience=bazaar-publisher")
      ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token')
    - |
      # Substitute <your-service> and the real route for your target service.
      curl -sS -X POST "https://api.flexgalaxy.ai/<your-service>/v1/..." \
        -H "Authorization: Bearer $ACCESS_TOKEN" \
        -F "artifact=@./build/my-artifact-1.0.0.tar.gz"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

id_tokens is the GitLab CI keyword that requests an OIDC token from GitLab’s built-in issuer (https://gitlab.com for hosted GitLab, the self-hosted instance URL otherwise). The aud field must be the literal string dotid-token-exchange.

The exchange call (raw)

If you are wiring this from a CI system other than GitHub Actions or GitLab CI, the underlying HTTP exchange is the same:

POST /oauth2/token HTTP/1.1
Host: iam.flexgalaxy.ai
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<your federated OIDC JWT>
&subject_token_type=urn:ietf:params:oauth:token-type:jwt
&audience=<target-service-id>

Required parameters

Parameter

Value

grant_type

urn:ietf:params:oauth:grant-type:token-exchange (RFC 8693, fixed)

subject_token

Your federated OIDC JWT

subject_token_type

urn:ietf:params:oauth:token-type:jwt (only value accepted)

audience

Target FlexGalaxy.AI service identifier (no default; you must supply one). Examples: bazaar-publisher, wms-fleet, pspec-write

Optional / ignored parameters

Parameter

What DotID does

requested_token_type

Defaults to urn:ietf:params:oauth:token-type:access_token; only that value is accepted. Sending any other value returns 400 unsupported_token_type.

scope

Ignored — scope is computed from your IAM policy grants. Sending scope has no effect on the response.

resource

Ignored — duplicates audience.

actor_token

Ignored — delegation chains are not currently supported.

Successful response (200)

{
  "access_token": "eyJraWQiOiJ...<DotID-signed JWT>",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "bazaar:Artifact:Publish"
}
  • expires_in is always 900 (15 minutes). There is no longer TTL available.

  • There is no refresh_token field. Re-exchange on each CI run by requesting a fresh OIDC token from your IdP. This is intentional — workload tokens are deliberately short-lived.

  • scope is the PDP-computed grant set. Treat it as informational; the actual authorization happens at the target service when it validates the JWT.

Error decoder — when invalid_grant comes back

DotID intentionally collapses every subject-token rejection into 401 invalid_grant with a generic message. This is by design (it prevents probing for valid issuers, subjects, or claim shapes), but it means you have to disambiguate from your side.

Below is the canonical decoder. Walk it top-to-bottom on every invalid_grant.

Symptom

Most likely cause

What to check / fix

401 invalid_grant and you just set this up for the first time

Audience mismatch on the subject token

Your CI is requesting OIDC with the wrong audience. In GitHub Actions, check that the URL passed to ACTIONS_ID_TOKEN_REQUEST_URL ends with &audience=dotid-token-exchange literally. In GitLab CI, check id_tokens: DOTID_TOKEN: aud: dotid-token-exchange. Any other value (including dotid, flexgalaxy, your own service name) will be rejected.

401 invalid_grant after a working pipeline starts failing

Federated subject changed

GitHub OIDC subjects encode the branch (refs/heads/main), environment, or PR shape. A workflow that worked on main will fail if it runs on a feature branch unless the account admin bound that branch’s subject too. Decode your subject token at https://jwt.io and confirm the sub claim matches what your admin bound.

401 invalid_grant from a brand-new issuer

Issuer not in trust root

The federation trust root is a fixed allowlist of OIDC issuers. An issuer that is not on the allowlist is rejected. Adding an issuer requires a configuration change on the FlexGalaxy.AI side — contact FlexGalaxy.AI support with your issuer URL.

401 invalid_grant shortly after a known JWKS rotation

Stale JWKS cache on DotID

DotID refreshes its cached copy of your IdP’s JWKS once on a signature failure. If you’re hitting this within seconds of a rotation, retry with a fresh OIDC token after 30 seconds. Persistent failures across multiple minutes indicate a real issue — contact FlexGalaxy.AI support and reference your federated-subject (iss, sub) so the issue can be traced.

401 invalid_grant and the subject token’s exp is older than 5 minutes

Subject token expired

Federated OIDC tokens are minutes-scale. Request a fresh OIDC token immediately before each exchange call; do not cache them across steps.

403 access_denied (not invalid_grant!)

Subject token is valid but IAM policy denies the exchange

Your subject token validated successfully. Either the IAM policy is missing, names the wrong resource, or carries a Deny that wins. The resource MUST be arn:fg:dotid::service/<audience> matching the audience you sent. Have your account admin re-check the policy. This denial is recorded in your account’s audit trail.

Other status codes

Status

RFC 8693 error

What happened

400

invalid_request

A required parameter is missing or malformed (e.g. audience is empty, or grant_type is wrong). The error description names the offending parameter.

400

unsupported_token_type

You sent a subject_token_type other than urn:ietf:params:oauth:token-type:jwt, or a requested_token_type other than urn:ietf:params:oauth:token-type:access_token.

403

access_denied

PDP denied iam:ExchangeToken. See the row above.

5xx

(no body)

DotID-side issue. Retry with backoff; if persistent, status page + support.

What’s NOT supported

These are deliberate omissions, not bugs. If you need one of them, contact FlexGalaxy.AI support rather than filing an issue.

  • Refresh tokens. Workload tokens are intentionally short-lived. Re-exchange on each CI run. Per RFC 8693 §2.2.1, DotID is permitted to omit refresh_token and does.

  • actor_token delegation chains. Not supported.

  • resource parameter (RFC 8707). Accepted but ignored — audience is the only routing input.

  • Token introspection endpoint (RFC 7662). Consumers validate the JWT signature locally against the DotID JWKS (see api-auth-jwt-validation.md).

  • AWS STS / Azure AD / corporate-SSO federation. Not supported.

  • Federation against a custom OIDC IdP your team operates. The trust-root issuer set is fixed; contact FlexGalaxy.AI support if you need an issuer added.

Treating the exchanged token

The token DotID returns is a normal DotID-issued JWT. To consume it at the target service, follow the standard pattern in api-auth-jwt-validation.md:

  • iss is the DotID realm URL.

  • aud matches the audience parameter you sent (bazaar-publisher, etc.).

  • sub is your workload identity ID.

  • scope is the PDP-computed grant set.

  • The custom claim federated_subject carries {issuer, sub} from the input OIDC token — your service may use it for audit correlation but must not use it for authorization decisions (the aud/sub/scope claims are the authorization surface).

Validate the JWT signature against the realm JWKS. Cache JWKS responses per their Cache-Control headers. Reject expired tokens by exp.

Re-exchange cadence

A naïve “exchange once at job start” pattern leaves a 15-minute access token sitting in your job environment for the rest of the job. Two refinements:

  • One exchange per call site if your job calls several FlexGalaxy.AI services. Exchange separately for each audience (one token per target service), and let each token expire on its own.

  • Re-exchange on 401 from the target service. The target service returns 401 when the token expires; your client catches it, re-requests a fresh OIDC token from the IdP, re-exchanges, and retries the original call. Bound retries to 1.

For most CI jobs (< 15 min total), exchange-once is fine.

Local testing without GitHub / GitLab

You cannot generate a valid OIDC token from a trusted issuer outside of a real workflow — that’s the whole point of the trust root. If you need help exercising the FlexGalaxy.AI side of your integration before wiring it into a live pipeline, contact FlexGalaxy.AI support.

See also

  • oidc-client-setup.md — the different thing where you register an OIDC client (a browser/app that accepts user logins). Workload federation does NOT register a client; it binds an external OIDC subject to a DotID workload identity.

  • api-auth-jwt-validation.md — how to validate any DotID-issued JWT at the target service.

  • api-overview.md — the FlexGalaxy.AI API surface.

  • frn-format.md — FRN (resource ARN) format. The iam:ExchangeToken resource pattern is arn:fg:dotid::service/<target>.