๐Ÿ“ฆ EqualifyEverything / equalify-iris

๐Ÿ“„ github-auth.md ยท 140 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140# GitHub sign-in, for operators

Iris has one identity provider: by default every request carries a user's GitHub token, and that
token is what files the session's contributions under that user's own name. There is no second
provider and no API key โ€” the only way to serve callers without a token is the demo mode in
[`github.anonymous_token`](#anonymous-access-a-demo-you-turn-on), which is off unless you set it. The
[README](../README.md#github-is-the-only-sso-layer-and-tokens-are-required) says why. This file is
the part you need to *deploy* it โ€” registering your own app, what a private upstream can and cannot
do, and what to do with a database from an older build.

By default you need none of it: the bundled GitHub App and the device flow work with no setup and no
secret, the same way the `gh` CLI does.

## Two consequences before you deploy

**1. The permission lives with the installation, not with your users.** The token does exactly two
things: `GET /user` to identify the caller, and file issues on `upstream_repo`. Iris is registered as
a **GitHub App**, so the second one is granted once โ€” by installing the app on `upstream_repo` with
`issues: write` โ€” and users only *authorize*. Their consent screen requests **no repository access
at all**, because there is nothing left for it to ask for.

One limit worth knowing if your `upstream_repo` is **private**: a user's token is the *intersection*
of the installation's permissions and that user's own access, so installing the app does not give a
user access they did not already have. On a private upstream, filing works for users who can see the
repo and 404s for everyone else. Set `github.issue_token` if you need a private upstream to accept
contributions from users who are not collaborators โ€” it files everything under one account, which
trades away the per-user attribution below. A public `upstream_repo` (the assumption here, since the
agent library is meant to be shared) has no such limit.

This replaced an OAuth App requesting `public_repo`, and the reason is worth stating plainly: there
is no OAuth scope meaning "open issues on one repository". `public_repo` was the narrowest one that
could file, and it grants read **and write** to every public repository the user can reach โ€”
code, commit statuses, collaborators, webhooks โ€” none of which Iris touches. Nothing pushes and
nothing opens pull requests. So the old consent screen asked for orders of magnitude more than the
service uses, and the app is the only way to fix that rather than merely document it.

What the user's token still carries is their **identity**. A user-to-server token acts as the user,
so issues are filed under their own account and each contribution is credited to the person whose
session produced it โ€” the whole reason users authorize at all instead of the app filing as itself.

**2. `github.issue_token` is an override, and not a recommended one.** Set it to a service-account
PAT and every issue is filed under that bot account instead of under the user who produced it. It is
off by default because it erases the attribution that is the point of the design. Use it only where
a deployment genuinely cannot file as its users โ€” an org policy that forbids it, say.

## Anonymous access: a demo you turn on

Set `github.anonymous_token` to a token for a **dedicated** GitHub account and a caller who sends
**no** `Authorization` header is served as that account instead of refused. Unset โ€” the default โ€” a
token is required on every call. The reason to turn it on is a visitor who wants to see Iris work on
one page before deciding whether to sign in. Make it an account no person signs in with: the row
below on the shared identity says why.

Four things it costs, and Iris prints them at every boot so they are not a surprise later:

| What changes | Why |
| --- | --- |
| `GET /v1/sessions` answers **403 `anonymous_session_list`** | Ownership is the GitHub user id and nothing else, so every anonymous visitor is the same owner. Listing "their" sessions would hand one visitor another's document. A session is still reachable at `GET /v1/sessions/{id}` with the id `POST /v1/sessions` returned. |
| **That account** gets the same 403, signed in or not | The refusal is keyed on the identity a request reaches, not on whether it sent a header, so presenting this token as an ordinary `Bearer` is refused too. The alternative is not a convenience: it would make the token a key to every visitor's uploads, with no server access needed. This is the cost of a shared identity, so a dedicated account pays it and nobody notices. |
| Uploads are counted per **address**, not per user | One shared account keyed per user would make `upload_per_minute` a single bucket for every anonymous caller on the internet, and the symptom is a deployment that looks healthy and is permanently rate limited. |
| Feedback is filed under **that** account | An anonymous session has no user to credit. This is the attribution the default protects, so a deployment that cares about it should leave the key blank. A 403 while filing names `github.anonymous_token` in its `hint`, because the GitHub App's installation cannot be the cause. |

Two details worth knowing before you deploy it:

- **A broken token is still refused.** The fallback serves callers who present *nothing*. A request
  with `Bearer <expired>` or a non-Bearer header gets a 401, because a client that is trying to be
  someone should see its own sign-in fail, not be moved silently into a shared account.
- **The credential is validated like any other**, with the same `GET /user` and the same 5-minute
  cache. A revoked or mistyped value there does not produce a phantom user โ€” it makes every anonymous
  request 401, and the failure is in the boot log rather than the caller's response.

Clients detect the mode by calling `GET /v1/me` with no token: **200** with `anonymous: true` means
anonymous use is allowed here, **401** means it is not. That answer cannot go stale, because it is
the same code path a real anonymous request takes.

## Registering your own app

Two settings the service depends on, if you point `github.client_id` at your own GitHub App:

| Setting | Value | Why |
| --- | --- | --- |
| **Enable Device Flow** | on | Off by default for a new app, and the device flow is the default deployment's only login path (it returns `device_flow_disabled` without it). |
| **Expire user authorization tokens** | **off** | With expiry on, user tokens last 8 hours and come with a refresh token. Nothing here persists or refreshes a credential, so turning expiry on means building refresh plumbing first. |

The misconfiguration this *cannot* catch at startup is the app not being installed on
`upstream_repo` โ€” that state lives on github.com, not in config. It surfaces as a **403 or 404**
during filing, logged with a `hint` saying so. Both statuses, because GitHub does not reveal
repositories a credential cannot see: an app that was never installed reads as `404 Not Found`
rather than as a permissions error. (A misspelled `upstream_repo` looks identical, and the hint says
so rather than blaming the installation.) When `issue_token` is set, the hint names the **service
PAT** instead, since the installation governs only tokens issued to users.

## Coming from an earlier build

Three things changed, and two of them can stop a working deployment:

- **A configured OAuth App id is now a hard startup failure.** An `Ovโ€ฆ` `client_id` is refused,
  because Iris no longer sends any OAuth scope: such an app would authenticate users and then be
  unable to file a single issue. Register a GitHub App (`Ivโ€ฆ`) and install it on your
  `upstream_repo`, or leave `client_id` blank for the bundled one.
- **`upstream_repo` is no longer independent of `client_id`.** Under the old OAuth App, the
  `public_repo` scope could file on any public repo, so leaving `client_id` blank and repointing
  `upstream_repo` at your own agent library worked. A GitHub App's `issues: write` comes from its
  *installation* on one specific repository, and the bundled app is installed on this repo โ€” so that
  same config now files nothing, for anyone. You need your own app installed on your repo (or ask us
  to install ours there). This combination warns at startup rather than failing, since we cannot see
  from config whether the bundled app was installed on your repo.
- **`github.oauth_scope` is gone.** A config that still sets it โ€” including `oauth_scope: none`,
  which used to be a startup error โ€” now starts fine and ignores the key. Delete it.

There is no user-facing migration: no one had authorized the OAuth App, and any existing
authorization can be revoked at
[github.com/settings/applications](https://github.com/settings/applications).

## What happens to a token

**It is never written to disk.** The token arrives in the `Authorization` header, is used in memory
for the request and for the pipeline run it authorizes, and is gone when the run ends. There is no
`github_token` column in `data/iris.sqlite` and no token file โ€” a stolen copy of the database is a
list of GitHub user IDs and logins, not GitHub access.

Two smaller things follow from that, both worth knowing:

- Identity lookups (`GET /user`) are cached in memory for **5 minutes**, keyed by the token, so a
  revoked token keeps working for up to that long. The cache is bounded (10,000 entries, oldest
  evicted) and entries are *not* renewed on use โ€” deliberately, so that a busy token cannot outlive
  its revocation indefinitely. It is empty on restart.
- Because nothing is stored, there is nothing to rotate, re-encrypt or purge when a user revokes
  access. Revocation at github.com is the whole mechanism.

**If you have a `data/iris.sqlite` from an earlier build, delete it.** Tokens *were* stored in a
`github_token` column once, and there is no migration โ€” every user re-authorizes from scratch. The
service refuses to start against such a file and names the fix, rather than adopting it: the old
table's `github_token TEXT NOT NULL` would survive `CREATE TABLE IF NOT EXISTS`, so first-time
logins would fail with a SQLite constraint error returned as `401 unauthorized` (users who already
had a row would keep working, which makes it look like flaky GitHub auth rather than a schema
mismatch) โ€” and the claim above would be false for that file, since it still holds live plaintext
tokens for everyone who ever logged in. Delete it rather than archiving it; users lose only their
session history.