# GitHub Apps

A GitHub App authenticating as itself: an app JWT, installation tokens and a bot user whose standing is the token's grant.

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:

```bash
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 token | Standing |
|---|---|
| doesn't reach the repository (by its narrowing, else its installation's) | none |
| belongs to a suspended installation | none |
| grants `administration: write` | admin |
| grants any other repository write permission | write |
| otherwise | read |

**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:

| Write | Result |
|---|---|
| creating a pull request, an issue, a review, a review comment or a reply; merging; dismissing a review; requesting a review | `403 Resource not accessible by integration` |
| issue comments, labels, assignees and milestones | accepted: they check no write access |
| creating a commit status | refused 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.

---

Source: https://www.rystic.ai/docs/simulators/github-pulls/features/github-apps · Markdown: https://www.rystic.ai/docs/simulators/github-pulls/features/github-apps.md
