# Workload identity

A trust lets a job that already has an identity sign in to ADoor. This is standard OpenID Connect workload identity: the same kind of token GitHub Actions uses to sign in to AWS, Google Cloud, and Azure. The job asks its issuer for a short-lived JWT. ADoor checks that the issuer signed it, that it was issued for this ADoor server, and that the subject is one you chose. ADoor then issues its own credential, capped by the access you set. You do not paste a long-lived secret into the job.

ADoor does not special-case a cloud provider. GitHub Actions is the worked example below. Another issuer can be trusted when it signs with RS256, ES256, or PS256, publishes a public HTTPS key set, sets the token audience to the ADoor origin, and includes a `jti`. There is no separate Cloudflare, AWS, or Google setup.

## What you are trusting

You are trusting the issuer to sign only true claims. GitHub fills the `sub` claim from the job that is actually running: the repository, and the branch, environment, or pull request. Another repository cannot forge your subject, because it does not hold GitHub's signing key.

The key set URL is how ADoor learns that public key. There is no field for a key. ADoor fetches a JWKS document from the URL and checks the token signature against the key named by `kid`. Pointing that URL at any other address means trusting whoever controls it. ADoor will accept tokens signed by a key published there.

## What a trust stores

- A name, for the Access page.
- The issuer. This is the token's `iss` claim. GitHub Actions uses `https://token.actions.githubusercontent.com`.
- The key set URL. GitHub publishes its keys at `https://token.actions.githubusercontent.com/.well-known/jwks`.
- A subject. The token's `sub` claim must match it. An exact string matches only itself. A final `*` after `/` or `:` matches one prefix, so `repo:octo-org/octo-repo:*` matches that repository's branches, tags, environments, and pull requests. `repo:octo-org*` is rejected, because it would also match `repo:octo-org-evil`.
- The access the exchanged identity is capped to. On the website, the access you choose is stored on the trust and is the cap. That choice is table access. A workload cannot receive account access: profile, registered apps, creating workspaces, and browser sessions stay with the person. The exchanged identity also cannot exceed your own access at the moment of the exchange. It lasts 15 minutes by default.

The website asks for those fields. The `federations.create` operation, run while signed in from the Operation catalog in the app, can also require extra claims to match exactly, set the maximum lifetime from 60 seconds up to 30 days with `maxTtlSeconds`, and choose the kind of connection the job becomes with `childRole`. The default kind is federated. An exchange can ask for a shorter lifetime with `expires_in`.

## How ADoor checks a token

The job gets a JWT from the issuer, then exchanges it at `POST /oauth/token`. Two different values are both called an audience:

- The JWT `aud` claim must be this ADoor server's origin, with no path and no trailing slash. For the hosted app that is `https://app.adoor.halmoslabs.com`.
- The `audience` field of the exchange request must be the trust id, which ADoor shows after you create the trust. Pass it every time. If you leave it out, the subject has to match exactly one trust before a key set is fetched. Zero matches and several matches return the same error: `No trusted issuer matches this token.`

ADoor then checks, in order:

1. A live trust has this issuer and this server's origin. When you name a trust id, it has to be that trust.
2. The signature is RS256, ES256, or PS256. `none` and HMAC (`HS*`) are rejected. The key is the JWKS entry with the same `kid`. A key that includes a private `d` is ignored.
3. `iss` is the trusted issuer. `aud` contains this server's origin. `exp`, `nbf`, and `iat` are acceptable, with 60 seconds of clock skew. `exp` minus `iat` is at most 3600 seconds.
4. The subject matches, and any exact claims you required match.
5. `jti` is present. ADoor stores it until the token's own `exp`. Sending the same token again is rejected and returns no credential.

The key set must be HTTPS, on a public address, with no username or password. Redirects are not followed. ADoor reads at most 256 KiB and waits at most 5 seconds. A localhost or private address is rejected, so a key set has to be published somewhere ADoor can reach. A failed fetch is remembered for 30 seconds.

The new credential cannot exceed the trust's cap or your current access. Revoking the trust makes the next exchange fail, and credentials it already issued fail on their next request.

The exchange can ask for less than the cap by sending `authorization_details` of type `adoor_space_access`. Asking for more is rejected. The same signed JWT is also accepted as `urn:ietf:params:oauth:grant-type:jwt-bearer` (field `assertion`) and as `client_credentials` with `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.

## Keep a trust narrow

Prefer one repository and one branch or environment. `repo:octo-org/*` lets every repository in that organization sign in. Leave out delete and schema changes unless the job needs them. The `claims` field of `federations.create` can require an exact claim, for example `"repository": "octo-org/octo-repo"`. The website form does not ask for extra claims.

If the job sets an environment, GitHub puts the environment in `sub` and does not include the branch. A trust for `repo:octo-org/octo-repo:ref:refs/heads/main` will not accept that job.

## GitHub Actions

GitHub's issuer and key set are the two URLs above. The workflow must allow `id-token: write`, or GitHub will not mint a token. Request the token with `audience` set to the ADoor origin. That becomes the JWT `aud` claim.

GitHub documents the subject formats in its [OpenID Connect reference](https://docs.github.com/en/actions/reference/openid-connect-reference). Use the format your repository actually issues.

Name form, for a repository that still uses it:

- Branch: `repo:octo-org/octo-repo:ref:refs/heads/demo-branch`
- Environment: `repo:octo-org/octo-repo:environment:Production`
- Pull request, and only when the job does not set an environment: `repo:octo-org/octo-repo:pull_request`

Repositories created after 15 July 2026, and older repositories that opted in, put the owner id and repository id in the subject. GitHub's published branch example is `repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main`. Keep the environment or pull request suffix after `repo:OWNER@OWNER_ID/REPO@REPO_ID`, and confirm the exact `sub` before saving the trust. A custom subject template replaces the whole `sub` claim. The `github/actions-oidc-debugger` action prints the claims. GitHub Enterprise Server does not use the immutable format.

In ADoor, open Access, choose Trust an issuer, and confirm the GitHub issuer and key set. Set the subject to one line above, with your organization, repository, and branch or environment. Choose the access the job may have. Copy the trust id.

```yaml
permissions:
  id-token: write
  contents: read

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - name: Exchange a GitHub token for an ADoor credential
        env:
          ADOOR_ORIGIN: https://app.adoor.halmoslabs.com
          ADOOR_TRUST_ID: PASTE_THE_TRUST_ID
        run: |
          set -eu
          jwt=$(curl -sS \
            -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$ADOOR_ORIGIN" \
            | jq -r .value)
          response=$(curl -sS -X POST "$ADOOR_ORIGIN/oauth/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=$jwt" \
            --data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:jwt' \
            --data-urlencode "audience=$ADOOR_TRUST_ID")
          token=$(jq -r .access_token <<<"$response")
          test -n "$token" && test "$token" != null
          curl -sS "$ADOOR_ORIGIN/api/identity" -H "Authorization: Bearer $token"
```

`ADOOR_ORIGIN` must be the origin only. `ADOOR_TRUST_ID` is the trust id, not that origin. The last request shows the short-lived credential. The same bearer token is what the API and MCP accept. Each GitHub token can be exchanged once; request a new one for another run.

## Revoking a trust

A trust has no credential of its own. Creating one returns its id, not a token, and only an exchange produces a credential. Revoke the trust on Access to stop new exchanges. Credentials it already issued fail on their next request.
