Identities for entities did.bot
agent llm did
didbot docs pipelines.md
14 kB
Markdown
at main

From AWS OIDC to didbot #

A GitHub Actions workflow that assumes an AWS role through OIDC becomes a didbot pipeline with one command on the operator's machine and two steps in the workflow. This page is the port, piece by piece: what each part of the AWS setup becomes, a workflow to paste, how the trust policy's wildcards are spelled when only equality exists, which claim to bind so a renamed repository cannot take the account, and the lines that differ for Google, Kubernetes, Azure, GitLab and Cognito. Who operates an account is the model; the command line documents every command used here.

Side by side #

AWS didbot
An IAM OIDC identity provider for https://token.actions.githubusercontent.com, with the issuer's thumbprint and audience sts.amazonaws.com Nothing to register. The server fetches the issuer's keys through its discovery document when a token arrives. [oidc] issuers in the server's config narrows which issuers it will read; empty admits any public HTTPS issuer.
A role whose trust policy has StringEquals token.actions.githubusercontent.com:aud: sts.amazonaws.com and StringLike token.actions.githubusercontent.com:sub: repo:org/repo:* didbot operate deploy.pds.example op.example --kind pipeline --oidc https://token.actions.githubusercontent.com repository_id=456789 --creates agent, once. The account did:web:deploy.pds.example is the identity: the server writes the issuer and the claims as the account's bot.did.credential record.
The audience sts.amazonaws.com The server's own DID, did:web:pds.example. GitHub mints a token for any audience string.
aws-actions/configure-aws-credentials with role-to-assume (AssumeRoleWithWebIdentity) Request the token yourself with audience=did:web:pds.example, write it to a file, and didbot oauth pending --token-file <file>: a session as the pipeline.
Temporary credentials, one hour by default A session, one hour by default ([sessions] proof_ttl_secs); a run that needs longer presents a fresh token.
The role's permissions policy The session writes the pipeline's own repository and nothing else. Whether the pipeline may create accounts beneath itself is the creates entry on the operator's record for it: --creates agent above lets each run start an agent, and a record without it admits a pipeline that creates nothing.
StringLike and ForAnyValue conditions Equality on the claims GitHub already splits out of sub; see below.

The trust policy in GitHub's own AWS instructions matches sub against repo:octo-org/octo-repo:ref:refs/heads/octo-branch, which is a name somebody else can hold later. AWS cannot condition on repository_id: its OIDC federation condition keys are amr, aud, email, oaud and sub, so a role that wants the ID opts the repository into GitHub's immutable subject and matches repo:octo-org@123456/octo-repo@456789:*. didbot compares any claim, so it binds repository_id itself.

The workflow #

permissions:
  id-token: write        # the run may mint an OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      DIDBOT_PDS: https://pds.example
    steps:
      - uses: actions/checkout@v4

      - name: Sign in as the pipeline
        run: |
          # One token per didbot command: a token is spent the first time
          # it is presented, and it must be under five minutes old.
          curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=did:web:pds.example" \
            | jq -r .value > "$RUNNER_TEMP/id-token"
          didbot oauth pending --token-file "$RUNNER_TEMP/id-token"

      - name: Create this run's agent beneath the pipeline
        run: |
          curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=did:web:pds.example" \
            | jq -r .value > "$RUNNER_TEMP/id-token"
          didbot register agent "run-$GITHUB_RUN_ID.pds.example" \
            --under deploy.pds.example --server pds.example \
            --token-file "$RUNNER_TEMP/id-token"

didbot oauth keeps the session where DIDBOT_ACCOUNT_TOKEN_FILE points didbot-oauth; every later didbot oauth in the job runs as the pipeline. A token is never an argument: --token <JWT> is refused so ps and the job log cannot show one.

A name can be recycled #

An account here is permanent, and its OpenID Connect identity is an issuer and a set of claims. Bind a claim that carries a name and the account belongs to whoever holds that name today. Rename a GitHub repository, transfer it to another owner, or delete it and create one with the same name, and a workflow in the new repository mints a token whose repository is the old value. That token is a session as the pipeline, and the new owner writes the pipeline's own repository.

Numeric IDs are handed out once and never reused, so bind those. GitHub added immutable subject claims on 2026-04-23, and since 2026-07-15 every repository created, renamed or transferred gets them: sub reads repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main. The same IDs arrive as claims of their own, repository_id and repository_owner_id, and those are what --oidc names. Azure asks for the same pair: a flexible federated identity credential for GitHub must match repository_id, repository_owner_id or both, whatever its sub says.

Which claim to bind #

Every issuer below hands out a name and an ID for the same thing. The name is what a person reads and what a run prints; the ID is what an account binds.

Issuer Bind Not
GitHub Actions repository_id, repository_owner_id repository, repository_owner, and a sub without its @<id> halves
GitLab CI project_id, namespace_id project_path, namespace_path — a reclaimed path is why GitLab stops issuing tokens until the sub is rebuilt around project_id
Google service accounts sub, the service account's numeric unique ID email — delete a service account and create one with that name again, and the address is back with a new ID
Kubernetes /kubernetes.io/serviceaccount/uid sub, system:serviceaccount:<namespace>:<name> — a service account deleted and recreated carries the same one
Azure AD / Entra sub, the workload identity's object ID a display name
Amazon Cognito sub, the pool's own identifier for the user email, preferred_username

Where the wildcard went #

AWS sees sub and aud and matches sub with StringLike. GitHub also puts each part of sub in a claim of its own, and didbot matches those claims for equality, so what was a pattern is a choice of claim:

Intent AWS sub condition --oidc https://token.actions.githubusercontent.com …
Any branch, tag or environment of one repository repo:org/repo:* repository_id=456789
One branch repo:org/repo:ref:refs/heads/main repository_id=456789 ref=refs/heads/main
Tags only repo:org/repo:ref:refs/tags/* repository_id=456789 ref_type=tag
One environment repo:org/repo:environment:production repository_id=456789 environment=production
One reusable workflow, wherever it is called job_workflow_ref custom claim job_workflow_ref=org/workflows/.github/workflows/deploy.yml@refs/heads/main, a path — add repository_owner_id=123456 so only that owner's repositories call it
Every repository of one owner repo:org/* repository_owner_id=123456 — any repository anyone creates under that owner, including a fork's pull request if the workflow runs there
Several repositories one StringLike per pattern one pipeline account per repository, or repository_owner_id; a claim set is a conjunction

456789 is org/repo's ID and 123456 its owner's. Both ride in every token the repository's workflows mint, and the repository's OIDC settings show the sub a workflow emits before anything relies on it.

Two accounts at one issuer may overlap — deploy with repository_id=456789 and deploy-prod with repository_id=456789 environment=production. A production run's token matches both and is the account naming the most claims, deploy-prod; a staging run's is deploy. Two accounts naming as many claims as each other, both matching, is a refusal.

A claim whose value is a number or a boolean is spelled as JSON spells it: email_verified=true. A nested claim is named as a JSON pointer: /kubernetes.io/namespace=agents. A top-level claim may be named either way, so repository and /repository are one claim, and an identity that names one claim twice is refused.

Lifetimes, and two runs at once #

GitHub's token carries iat at the moment it was requested. The server refuses a token whose iat is older than [oidc] freshness_secs (five minutes by default), so a workflow requests the token in the step that uses it, not in an earlier one. exp is checked too, and nbf when present.

A token is spent the first time it is presented, so two didbot commands in one job request two tokens. Two workflows running at once each request their own and each hold a session; a pipeline may hold [sessions] live_per_account sessions (eight by default), and the one expiring soonest makes room for the next.

Other platforms #

Each platform below is the --oidc line for didbot operate, the way its token reaches the machine, and what differs from GitHub.

Google service accounts (Compute Engine, Cloud Run, GKE workload identity). --oidc https://accounts.google.com sub=106589273465000000000. sub is the service account's numeric unique ID, which Google never hands out twice; email rides in the same token and is the address a person recognises. An instance reads its token from the metadata server with audience=did:web:pds.example&format=full. The token is an hour long and carries no jti; the server spends the whole token instead. A metadata server that caches the token hands the same one out again, and the same token presented twice is a replay; a token read more than five minutes before it is presented is stale. Register once per boot, straight after reading the token. Google's keys sit on www.googleapis.com, not the issuer's host, and are fetched from there.

Kubernetes service accounts. --oidc <issuer> /kubernetes.io/serviceaccount/uid=<uid>, from kubectl get sa runner -n agents -o jsonpath='{.metadata.uid}'. sub reads system:serviceaccount:agents:runner, and a service account deleted and recreated under that name carries the same one, so the UID is the binding and sub is the line a person reads in a log; one pod of that account adds /kubernetes.io/pod/name=runner-0. The issuer is the cluster's public one — EKS's https://oidc.eks.<region>.amazonaws.com/id/<id>, GKE's https://container.googleapis.com/v1/projects/<p>/locations/<l>/clusters/<c> — not https://kubernetes.default.svc, which resolves to a private address the server will not fetch from. The token is a projected volume with audience: did:web:pds.example; aud arrives as an array, which is fine. The kubelet rewrites the file only as it nears expiry, so a pod that registers later than five minutes after the file was written finds it stale: set expirationSeconds: 600 on the projection, the minimum, and read the file at the moment of registering.

Azure AD / Entra workload identity. --oidc https://login.microsoftonline.com/<tenant>/v2.0 sub=<object id>. The iss of a v2 token ends in /v2.0; a v1 token's is https://sts.windows.net/<tenant>/ and does not match a v2 entry. Azure's keys sit under /discovery/v2.0/keys, off the issuer's path, and are fetched from there.

GitLab CI. --oidc https://gitlab.com project_id=12345678, with ref_type=tag or environment=production the way GitHub's are; project_path is beside it in the token and moves with a rename. The token is id_tokens: in .gitlab-ci.yml with aud: did:web:pds.example.

Amazon Cognito identity pools. --oidc https://cognito-idp.<region>.amazonaws.com/<pool> sub=<subject>. Both RSA (RS256) and EC (ES256) signatures verify; RS512 and PS256 too.