Skip to Content
SimulatorsGitHubGitHub Apps

Simulators

GitHub Apps

A GitHub App signs a short-lived JWT, exchanges it for an installation token and acts as its bot user, and the simulator serves that whole path. Sign a JWT with any RSA key, read the installation, mint a token and write as the bot:

b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
key=$(mktemp -d)/app.pem; openssl genrsa -out $key 2048 2>/dev/null
hdr=$(printf '{"alg":"RS256","typ":"JWT"}' | b64)
claims=$(printf '{"iss":4125,"iat":1769904000,"exp":1769904540}' | b64)
jwt="$hdr.$claims.$(printf '%s' "$hdr.$claims" | openssl dgst -sha256 -sign $key | b64)"
curl -s "$RYSTIC_URL/repos/tb-owner/tb-repo/installation" -H "Authorization: Bearer $jwt" | jq -c '{id, app_slug, repository_selection}'
# -> {"id":41250001,"app_slug":"tb-agent","repository_selection":"all"}
tok=$(curl -s -X POST "$RYSTIC_URL/app/installations/41250001/access_tokens" -H "Authorization: Bearer $jwt" | jq -r .token)
curl -s -X POST "$RYSTIC_URL/repos/tb-owner/tb-repo/issues" -H "Authorization: token $tok" -H 'Content-Type: application/json' -d '{"title":"opened by the app"}' | jq -c '{number, user: {login: .user.login, type: .user.type}}'
# -> {"number":45,"user":{"login":"tb-agent[bot]","type":"Bot"}}
curl -s "$RYSTIC_URL/user" -H "Authorization: Bearer $jwt" | jq -c '{message}'
# -> {"message":"Bad credentials"}
curl -s -X DELETE "$RYSTIC_URL/installation/token" -H "Authorization: token $tok" -o /dev/null -w '%{http_code}\n'
# -> 204
curl -s "$RYSTIC_URL/repos/tb-owner/tb-repo/issues/45" -H "Authorization: token $tok" | jq -c '{message}'
# -> {"message":"Bad credentials"}

Octokit’s createAppAuth, Probot and actions/create-github-app-token make the same two calls: give them the app id 4125, any RSA private key and the simulator’s URL as the base URL.

The installed app

Every authored scenario installs one app, tb-agent (app id 4125, client id Iv23litbagent4125), on tb-owner’s account as installation 41250001. The installation covers every repository, with write on checks, contents, issues, pull_requests and statuses.

A scenario seeds its own app with state_github_app, state_installation and the bot’s state_user record (<slug>[bot], type Bot). Without that user record, the bot’s writes return 401 Bad credentials.

Differs from GitHub: the app’s own webhook (/app/hook) is not served, so an app-style agent registers a repository or organization hook instead.

The app JWT

The simulator reads a JWT by structure: an RS256 header, an iss naming a seeded app by id or client id, an integer iat and an exp at most eleven minutes after iat. That is GitHub’s ten minutes plus the minute its documentation back-dates iat by. Any other JWT returns 401 with A JSON web token could not be decoded, or GitHub’s message for the claim that failed.

Differs from GitHub: the simulator never checks the signature. It checks neither iat nor exp against a clock, because the simulator’s clock is not yours.

The JWT authenticates only the app routes and the token exchange:

  • GET /app
  • GET /app/installations
  • GET /app/installations/{id}
  • GET /repos/{owner}/{repo}/installation
  • GET /orgs/{org}/installation
  • GET /users/{username}/installation
  • POST /app/installations/{id}/access_tokens, the token exchange

On every other route, REST or GraphQL and read or write, a request carrying the JWT returns 401 Bad credentials before the handler runs, whether or not its claims would pass. The two installation-token routes below are the exceptions.

Installation tokens

POST /app/installations/{id}/access_tokens mints a ghs_ token, narrowed by repositories, repository_ids and permissions, that expires an hour later on the simulator’s clock. GET /installation/repositories lists what the token reaches, and DELETE /installation/token revokes it. Both refuse any credential that is not an installation token, the app JWT included. GET /installation/repositories returns 403 Resource not accessible by personal access token. Once a token the simulator minted is revoked or expired, every served route returns 401 Bad credentials to it.

Differs from GitHub: DELETE /installation/token returns 501 to any credential that is not an installation token.

A ghs_ token the simulator never minted, such as the GITHUB_TOKEN of a workflow run, is accepted like a personal token. X-Rystic-Actor still overrides the token’s identity.

What the bot may do

A request presenting an installation token acts as the app’s bot user, tb-agent[bot] (type Bot), so what it writes is the bot’s. Its standing on a repository is the token’s grant:

The tokenStanding
doesn’t reach the repository (by its narrowing, else its installation’s)none
belongs to a suspended installationnone
grants administration: writeadmin
grants any other repository write permissionwrite
otherwiseread

Differs from GitHub: the token’s permissions grant one standing per repository, not GitHub’s check per endpoint.

What a write does with a token whose grant doesn’t cover it:

WriteResult
creating a pull request, an issue, a review, a review comment or a reply; merging; dismissing a review; requesting a review403 Resource not accessible by integration
issue comments, labels, assignees and milestonesaccepted: they check no write access
creating a commit statusrefused whatever the grant: it asks for the repository’s owner or a collaborator

Differs from GitHub: issue comments, labels, assignees and milestones take the token whatever its grant, even a read-only one. GitHub refuses them without issues or pull_requests write.

Differs from GitHub: creating a commit status refuses the token even when it grants statuses: write.

What is not compared with GitHub

The account the simulator is compared with GitHub through uses a personal access token, not an app. Every response only an app’s JWT or installation token reaches is the simulator’s own behavior. Only a request with no credential or a malformed one is compared with GitHub.

Last updated on