@basein/runner 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/dist/auth/client.d.ts +141 -13
- package/dist/auth/client.js +301 -14
- package/dist/bin/bir-hooks.d.ts +13 -0
- package/dist/bin/bir-hooks.js +58 -0
- package/dist/bin/bir.js +99 -17
- package/dist/control/server.d.ts +84 -1
- package/dist/control/server.js +546 -51
- package/dist/control/transcript.d.ts +40 -0
- package/dist/control/transcript.js +105 -0
- package/dist/proxy/session.js +5 -4
- package/dist/record/recorder.d.ts +178 -4
- package/dist/record/recorder.js +6 -0
- package/dist/record/remote-recorder.d.ts +20 -2
- package/dist/record/remote-recorder.js +66 -6
- package/dist/replay/bundle.d.ts +10 -1
- package/dist/replay/bundle.js +41 -3
- package/dist/replay/controller.d.ts +164 -5
- package/dist/replay/controller.js +556 -54
- package/dist/replay/coverage.js +2 -2
- package/dist/replay/derive.d.ts +20 -1
- package/dist/replay/derive.js +69 -12
- package/dist/replay/flatten.d.ts +125 -0
- package/dist/replay/flatten.js +182 -0
- package/dist/replay/handover.d.ts +60 -0
- package/dist/replay/handover.js +82 -0
- package/dist/replay/logic.d.ts +11 -0
- package/dist/replay/logic.js +17 -0
- package/dist/replay/plan.d.ts +105 -8
- package/dist/replay/plan.js +309 -47
- package/dist/replay/source-run.d.ts +24 -10
- package/dist/replay/source-run.js +65 -30
- package/dist/replay/types.d.ts +108 -5
- package/dist/replay/types.js +33 -3
- package/docs/calculatedReplayGuide.md +1 -1
- package/docs/installRun.md +28 -15
- package/docs/loginWeb.md +607 -0
- package/docs/my-first-sample.md +545 -0
- package/docs/quickstart.md +28 -3
- package/package.json +1 -1
package/docs/loginWeb.md
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
# loginWeb — make `bir login` sign in through the browser
|
|
2
|
+
|
|
3
|
+
> **Status: implemented (2026-09-08), in runner 0.2.0.** Steps 1–3 of §8 have
|
|
4
|
+
> landed on both sides; step 4 — deleting `--password` and the fallback — is the
|
|
5
|
+
> release after. §15 records what shipped and the two things that changed shape
|
|
6
|
+
> on the way. The rest of this document is the plan as written, kept as the
|
|
7
|
+
> record of why.
|
|
8
|
+
|
|
9
|
+
It covers both halves — the runner in this repo and the BaseIn service
|
|
10
|
+
(`BaseIntSever/BaseIn`, referred to below as *the service*) — because a web
|
|
11
|
+
sign-in is a contract between them.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 0. Context
|
|
16
|
+
|
|
17
|
+
Today `bir login` asks for an email and a password in the terminal and posts
|
|
18
|
+
them to `POST /auth/login` ([client.ts:392-408](../src/auth/client.ts#L392-L408)).
|
|
19
|
+
The answer — `{accessToken, refreshToken, expiresIn, user}` — is cached at
|
|
20
|
+
`~/.baseinstrunner/credentials.json`, mode 0600 ([client.ts:105](../src/auth/client.ts#L105)),
|
|
21
|
+
and from then on every process (`bir-hooks`, each `bir-proxy`, the CLI) reuses and
|
|
22
|
+
silently refreshes it. That second half works well and is not what this plan
|
|
23
|
+
changes.
|
|
24
|
+
|
|
25
|
+
The first half has four problems, one of which is a hard wall:
|
|
26
|
+
|
|
27
|
+
1. **Google-only accounts cannot use the CLI at all.** The console offers
|
|
28
|
+
*Continue with Google*, and the Google callback creates users with no
|
|
29
|
+
password (`users.password_hash` is nullable; `createUser` at `routes.ts:273` in the
|
|
30
|
+
service). For them `POST /auth/login` can only ever answer
|
|
31
|
+
`invalid_credentials`. There is no workaround short of setting a password
|
|
32
|
+
they never wanted.
|
|
33
|
+
2. **A password typed into a terminal is the weakest link we have.** It appears
|
|
34
|
+
in installer transcripts, it is the thing `describeAuthService`
|
|
35
|
+
([client.ts:235](../src/auth/client.ts#L235)) exists to protect — a whole
|
|
36
|
+
probe was built because passwords were being posted to the wrong host.
|
|
37
|
+
3. **Every future factor has to be built twice.** MFA, SSO, passkeys: anything
|
|
38
|
+
added to the console's sign-in would need a terminal re-implementation.
|
|
39
|
+
A browser flow inherits all of it for free.
|
|
40
|
+
4. **Headless machines have no good story.** [installRun.md §7](installRun.md)
|
|
41
|
+
still says *pre-seed `credentials.json`* — a shared long-lived secret copied
|
|
42
|
+
by hand.
|
|
43
|
+
|
|
44
|
+
**The change:** `bir login` starts a sign-in the *browser* completes. The
|
|
45
|
+
terminal shows a short code and a link; the user signs in on the console with
|
|
46
|
+
whatever they already use; the CLI receives the same session payload it receives
|
|
47
|
+
today. Nothing downstream of `credentials.json` changes.
|
|
48
|
+
|
|
49
|
+
### Not in scope
|
|
50
|
+
|
|
51
|
+
- The token model (24 h access JWT, rotating 30-day opaque refresh —
|
|
52
|
+
`config.ts:254-255`, `refresh.ts:72-81`). Unchanged.
|
|
53
|
+
- How `bir-hooks`, `bir-proxy` and `RemoteRecorder` use the cached session
|
|
54
|
+
([remote-recorder.ts:285-345](../src/record/remote-recorder.ts#L285-L345)).
|
|
55
|
+
Unchanged, by design — see §2 decision 2.
|
|
56
|
+
- Machine-to-machine credentials (runner/service tokens for fleets). A real
|
|
57
|
+
follow-on; §11.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 1. The flow
|
|
62
|
+
|
|
63
|
+
OAuth 2.0 **Device Authorization Grant**, [RFC 8628](https://www.rfc-editor.org/rfc/rfc8628),
|
|
64
|
+
the same shape as `gh auth login` and every cloud CLI.
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
terminal (bir login) service (api.) browser (console)
|
|
68
|
+
───────────────────── ────────────── ─────────────────
|
|
69
|
+
POST /auth/device/code ───────────────────▶ mint device_code (secret)
|
|
70
|
+
{clientName, clientVersion} + user_code "BKQM-TXZR"
|
|
71
|
+
◀── {deviceCode, userCode, verificationUri,
|
|
72
|
+
verificationUriComplete, expiresIn:600, interval:5}
|
|
73
|
+
|
|
74
|
+
prints:
|
|
75
|
+
Sign in to BaseIn (https://api.bi2202.com)
|
|
76
|
+
Open https://bi2202.com/activate
|
|
77
|
+
Code BKQM-TXZR
|
|
78
|
+
Waiting for approval… (Ctrl-C to cancel)
|
|
79
|
+
tries to open verificationUriComplete ─────────────────────────────────▶ /activate?code=BKQM-TXZR
|
|
80
|
+
├─ not signed in → /login
|
|
81
|
+
│ (password, Google, …)
|
|
82
|
+
│ → back to /activate?code=…
|
|
83
|
+
├─ GET /auth/device/lookup?code=…
|
|
84
|
+
│ "bir 0.2.0 on DESKTOP-PQVT0H2
|
|
85
|
+
│ (1.2.3.4, 40 s ago) wants to
|
|
86
|
+
│ sign in as you@example.com"
|
|
87
|
+
every 5 s: └─ [Approve] [Deny]
|
|
88
|
+
POST /auth/device/token {deviceCode} ────▶ pending? → 400 authorization_pending
|
|
89
|
+
too fast? → 400 slow_down
|
|
90
|
+
denied? → 400 access_denied
|
|
91
|
+
expired? → 400 expired_token
|
|
92
|
+
approved → 200 {accessToken, refreshToken,
|
|
93
|
+
expiresIn, user} (single use)
|
|
94
|
+
saveCredentials(…) → "Signed in to https://api.bi2202.com as you@example.com."
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Two properties make this the right grant, and both come from the RFC's design:
|
|
98
|
+
|
|
99
|
+
- **The low-entropy code is only ever typed on the already-authenticated side.**
|
|
100
|
+
The browser session proves *who*; the code only says *which pending request*.
|
|
101
|
+
The secret that actually redeems a session (`device_code`) never leaves the
|
|
102
|
+
machine that asked for it.
|
|
103
|
+
- **The machine running `bir` needs no browser, no open port, no display.** The
|
|
104
|
+
link can be opened on a phone. That is the headless story §0 was missing.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 2. Decisions
|
|
109
|
+
|
|
110
|
+
1. **Device grant, not a loopback redirect.** The alternative (RFC 8252: the CLI
|
|
111
|
+
opens a local `http://127.0.0.1:port` listener and the console redirects back
|
|
112
|
+
to it) is smoother on a desktop and useless over SSH, in a container, or on a
|
|
113
|
+
runner with no browser — which is exactly where `bir` runs. It also needs a
|
|
114
|
+
listening port (Windows firewall prompt) and a redirect-URI allowlist on the
|
|
115
|
+
service. §12 has the comparison.
|
|
116
|
+
2. **Same payload, same file.** `POST /auth/device/token` answers with the
|
|
117
|
+
existing `AuthSuccess` (`types.ts:15-20`) via the existing `issueSession()`.
|
|
118
|
+
The CLI maps it with the existing `toSession()` and writes the existing
|
|
119
|
+
`credentials.json`. `bir-hooks`, the proxies and the recorder's shared-file
|
|
120
|
+
refresh logic are not touched, and the smoke test's pre-seeded credentials
|
|
121
|
+
([smoke-replay.mjs:157-167](../test/smoke-replay.mjs#L157-L167)) keep working.
|
|
122
|
+
3. **Only `bir login` starts a sign-in.** Today `authenticate()` prompts from
|
|
123
|
+
*inside* any process that happens to have a TTY and no session
|
|
124
|
+
([client.ts:377-398](../src/auth/client.ts#L377-L398)) — including
|
|
125
|
+
`bir-hooks` ([bir-hooks.ts:89](../src/bin/bir-hooks.ts#L89)). With a browser
|
|
126
|
+
flow that would mean a control server printing a code and blocking for ten
|
|
127
|
+
minutes, possibly under a supervisor where nobody sees it. `authenticate()`
|
|
128
|
+
becomes strictly silent: cached → refreshed → `undefined` with the existing
|
|
129
|
+
`auth.no_session … run bir login` line. The prompt lives in one place.
|
|
130
|
+
4. **The password prompt survives one release, explicitly.** `bir login
|
|
131
|
+
--password` keeps the current flow, printing a deprecation line. It is also
|
|
132
|
+
the automatic fallback when the service answers `404` on
|
|
133
|
+
`/auth/device/code` — a new runner against an old service must still work.
|
|
134
|
+
Both go in the release after.
|
|
135
|
+
5. **Opening the browser is best-effort and dependency-free.** `package.json`
|
|
136
|
+
has zero runtime dependencies and stays that way: spawn `cmd /c start "" <url>`
|
|
137
|
+
on Windows, `open` on macOS, `xdg-open` elsewhere, detached, errors ignored.
|
|
138
|
+
Skipped under `--no-browser`, when `SSH_CONNECTION`/`SSH_TTY` is set, or when
|
|
139
|
+
stderr is not a TTY. The link and code are printed regardless, so the flow
|
|
140
|
+
never depends on the browser having opened.
|
|
141
|
+
6. **The approval page is a console route behind the existing guard.**
|
|
142
|
+
`/activate` sits inside `RequireAuth` like every other page (`App.tsx:92-135`),
|
|
143
|
+
so the console's sign-in — password, Google, verification links, the
|
|
144
|
+
"session expired" notice — is reused rather than reproduced. This is the
|
|
145
|
+
single reason the whole plan is small.
|
|
146
|
+
7. **Every device sign-in is a login event.** `logLogin({method: 'device'})` at
|
|
147
|
+
redeem time, so *Sign-in history* shows it with the runner's IP and agent.
|
|
148
|
+
8. **`bir logout` revokes on the server.** Today it only deletes the file
|
|
149
|
+
([bir.ts:1061-1064](../src/bin/bir.ts#L1061-L1064)). It should
|
|
150
|
+
`POST /auth/logout {refreshToken}` first — the endpoint exists and is
|
|
151
|
+
idempotent (`routes.ts:186-200`).
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 3. Service — the API
|
|
156
|
+
|
|
157
|
+
All under `/auth`, so they inherit `authRouter`'s zod-and-`asyncHandler` style
|
|
158
|
+
and the `/auth` mount (`index.ts:63`).
|
|
159
|
+
|
|
160
|
+
### 3.1 `POST /auth/device/code` — public
|
|
161
|
+
|
|
162
|
+
Request: `{ clientName?: string ≤ 100, clientVersion?: string ≤ 40 }` — the
|
|
163
|
+
runner sends `os.hostname()` and `packageVersion()`.
|
|
164
|
+
|
|
165
|
+
Response `200`:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"deviceCode": "…43 chars base64url…",
|
|
170
|
+
"userCode": "BKQM-TXZR",
|
|
171
|
+
"verificationUri": "https://bi2202.com/activate",
|
|
172
|
+
"verificationUriComplete": "https://bi2202.com/activate?code=BKQM-TXZR",
|
|
173
|
+
"expiresIn": 600,
|
|
174
|
+
"interval": 5
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`verificationUri` is `${config.webOrigin}/activate` — `webOrigin`
|
|
179
|
+
(`config.ts:51`) is already the one origin CORS trusts and the one the
|
|
180
|
+
verification-email links use, so it is the right source of truth.
|
|
181
|
+
|
|
182
|
+
### 3.2 `POST /auth/device/token` — public, polled
|
|
183
|
+
|
|
184
|
+
Request: `{ deviceCode }`. Responses follow RFC 8628 §3.5 exactly, so any
|
|
185
|
+
future client can reuse a standard library:
|
|
186
|
+
|
|
187
|
+
| State | Status | Body |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| not yet decided | 400 | `{"error":"authorization_pending"}` |
|
|
190
|
+
| polled faster than `interval` | 400 | `{"error":"slow_down"}` (and the row's interval grows by 5 s) |
|
|
191
|
+
| user pressed Deny | 400 | `{"error":"access_denied"}` |
|
|
192
|
+
| past `expires_at`, or already redeemed | 400 | `{"error":"expired_token"}` |
|
|
193
|
+
| unknown code | 400 | `{"error":"invalid_grant"}` |
|
|
194
|
+
| approved | 200 | `issueSession(user)` — the `AuthSuccess` shape |
|
|
195
|
+
|
|
196
|
+
Success **consumes** the row (`status='consumed'`) and writes the
|
|
197
|
+
`login_events` row with the *poll request's* `req` — the IP and user-agent
|
|
198
|
+
recorded are the runner's, which is the machine that just received a session.
|
|
199
|
+
|
|
200
|
+
### 3.3 `GET /auth/device/lookup?code=…` — `requireAuth`
|
|
201
|
+
|
|
202
|
+
Answers `{ clientName, clientVersion, ip, requestedAt }` for a **pending**
|
|
203
|
+
code, else `404 {"error":"unknown_code"}`. The page uses it to say what is
|
|
204
|
+
asking before offering *Approve*. Never reveals user data — there is none yet.
|
|
205
|
+
|
|
206
|
+
### 3.4 `POST /auth/device/approve` and `/deny` — `requireAuth`
|
|
207
|
+
|
|
208
|
+
Request: `{ userCode }`. Approve sets `user_id = req.auth.sub`,
|
|
209
|
+
`status='approved'`, `decided_at`. Deny sets `status='denied'`. Anything not
|
|
210
|
+
pending → `404 unknown_code`, deliberately the same answer for *expired*,
|
|
211
|
+
*already decided* and *never existed*: the approving side learns nothing it
|
|
212
|
+
could use to enumerate codes. `204` on success.
|
|
213
|
+
|
|
214
|
+
### 3.5 Rate limits — the one thing that will break if forgotten
|
|
215
|
+
|
|
216
|
+
`authLimiter` is **100 requests per 15 minutes per IP** on everything under
|
|
217
|
+
`/auth` (`index.ts:51-63`). A ten-minute device flow polling every five
|
|
218
|
+
seconds is **120 requests**: the CLI would be cut off by a `429 rate_limited`
|
|
219
|
+
two thirds of the way through, with no `slow_down` to tell it why.
|
|
220
|
+
|
|
221
|
+
So `/auth/device/token` needs its own limiter mounted before `authLimiter`
|
|
222
|
+
(generous per IP — say 600 / 15 min — because pacing is enforced per *code*
|
|
223
|
+
by `slow_down`), while `/auth/device/code` and `/approve` get a **stricter**
|
|
224
|
+
one (say 20 / 15 min per IP). With an 8-character code over a 20-letter
|
|
225
|
+
alphabet (2.6 × 10¹⁰ codes) and a 10-minute lifetime, 20 guesses per quarter
|
|
226
|
+
hour is not a threat; see §9.
|
|
227
|
+
|
|
228
|
+
### 3.6 Storage — `src/db.ts`
|
|
229
|
+
|
|
230
|
+
```sql
|
|
231
|
+
CREATE TABLE IF NOT EXISTS device_authorizations (
|
|
232
|
+
id TEXT PRIMARY KEY, -- 'da_' + uuid
|
|
233
|
+
device_code_hash TEXT NOT NULL UNIQUE, -- sha256, like refresh_tokens.token_hash
|
|
234
|
+
user_code TEXT NOT NULL, -- 'BKQMTXZR' (stored without the dash)
|
|
235
|
+
client_name TEXT,
|
|
236
|
+
client_version TEXT,
|
|
237
|
+
ip TEXT,
|
|
238
|
+
user_agent TEXT,
|
|
239
|
+
status TEXT NOT NULL, -- pending | approved | denied | consumed
|
|
240
|
+
user_id TEXT, -- set on approve
|
|
241
|
+
poll_interval INTEGER NOT NULL, -- seconds; grows on slow_down
|
|
242
|
+
last_poll_at TEXT,
|
|
243
|
+
created_at TEXT NOT NULL,
|
|
244
|
+
expires_at TEXT NOT NULL,
|
|
245
|
+
decided_at TEXT,
|
|
246
|
+
consumed_at TEXT
|
|
247
|
+
);
|
|
248
|
+
CREATE INDEX IF NOT EXISTS idx_device_auth_user_code ON device_authorizations(user_code, status);
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`user_code` uniqueness is enforced among *pending* rows at mint time (retry on
|
|
252
|
+
collision). Rows older than 24 h are deleted lazily when a new code is minted —
|
|
253
|
+
the same "housekeeping at the moment someone is looking" pattern
|
|
254
|
+
`recordings/routes.ts` already uses for pruning.
|
|
255
|
+
|
|
256
|
+
### 3.7 Code generation — `src/auth/device.ts` (new)
|
|
257
|
+
|
|
258
|
+
- `device_code`: `randomBytes(32).toString('base64url')`, stored hashed with
|
|
259
|
+
the `hashToken` from `refresh.ts:9`. Returned once.
|
|
260
|
+
- `user_code`: 8 characters from `BCDFGHJKLMNPQRSTVWXZ` — no vowels (no
|
|
261
|
+
accidental words), no digits, no `I/O/U/Y/A/E` (no `0/O`, `1/I` confusion) —
|
|
262
|
+
displayed as `XXXX-XXXX`. Comparison uppercases and strips `-` and spaces.
|
|
263
|
+
- Hand `logLogin` a new union member: `method: … | 'device'`
|
|
264
|
+
(`middleware/loginLogger.ts:10`).
|
|
265
|
+
- **Neither code may ever reach a log line.** `req.log` calls in these handlers
|
|
266
|
+
log the row `id`, never the codes.
|
|
267
|
+
|
|
268
|
+
### 3.8 Config
|
|
269
|
+
|
|
270
|
+
| Variable | Default | Meaning |
|
|
271
|
+
|---|---|---|
|
|
272
|
+
| `DEVICE_CODE_TTL` | `600` | seconds a pending code lives |
|
|
273
|
+
| `DEVICE_POLL_INTERVAL` | `5` | seconds between polls the CLI is told to wait |
|
|
274
|
+
|
|
275
|
+
Both read in `config.ts` next to the token TTLs.
|
|
276
|
+
|
|
277
|
+
### 3.9 Tests — `test/auth-device.test.ts`
|
|
278
|
+
|
|
279
|
+
Same harness as `test/smoke.test.ts` (`DATABASE_PATH=':memory:'`,
|
|
280
|
+
`createApp()`, `listen(0)`):
|
|
281
|
+
|
|
282
|
+
- mint → poll → `authorization_pending`; approve without a token → 401;
|
|
283
|
+
approve wrong code → 404; approve → poll → 200 with a session whose refresh
|
|
284
|
+
token then works on `/auth/refresh`; poll again → `expired_token`
|
|
285
|
+
(single use).
|
|
286
|
+
- deny → `access_denied`.
|
|
287
|
+
- `DEVICE_CODE_TTL=1` → `expired_token`; lookup of an expired code → 404.
|
|
288
|
+
- two polls inside `interval` → second is `slow_down` and the stored interval
|
|
289
|
+
grew.
|
|
290
|
+
- a `login_events` row exists with `method='device'` and the poller's IP.
|
|
291
|
+
- 121 polls in a row are **not** rate-limited by `authLimiter` (guards §3.5).
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## 4. Console — `web/`
|
|
296
|
+
|
|
297
|
+
### 4.1 `pages/Activate.tsx` (new) — route `/activate`
|
|
298
|
+
|
|
299
|
+
Registered in `App.tsx` **inside `RequireAuth`**, like `/profile`. Behaviour:
|
|
300
|
+
|
|
301
|
+
1. Read `?code=`. If absent, show one input (8 letters, auto-formats
|
|
302
|
+
`XXXX-XXXX`, accepts lowercase, ignores dashes).
|
|
303
|
+
2. `GET /auth/device/lookup?code=…` → render *who is asking*:
|
|
304
|
+
|
|
305
|
+
> **bir 0.2.0 on DESKTOP-PQVT0H2** asked to sign in as **you@example.com**
|
|
306
|
+
> from 1.2.3.4, 40 seconds ago.
|
|
307
|
+
> Only approve this if *you* just ran `bir login` on that machine.
|
|
308
|
+
> [Approve] [Deny]
|
|
309
|
+
|
|
310
|
+
3. Approve → `POST /auth/device/approve` → "Done. You can go back to your
|
|
311
|
+
terminal." Deny → the mirror sentence.
|
|
312
|
+
4. `404 unknown_code` → "That code isn't valid or has expired. Run `bir login`
|
|
313
|
+
again to get a new one." Nothing else is distinguishable, on purpose (§3.4).
|
|
314
|
+
|
|
315
|
+
Wire `deviceLookup`, `deviceApprove`, `deviceDeny` into `api.ts` using the
|
|
316
|
+
`api()`/`apiOk()` helpers from `auth.tsx`, so refresh-on-401 comes for free.
|
|
317
|
+
|
|
318
|
+
### 4.2 The Google return-path bug this exposes
|
|
319
|
+
|
|
320
|
+
The guard sends an unsigned-in visitor to `/login` with `from` set to
|
|
321
|
+
`/activate?code=…`, and `SignInCard` navigates back to it after a password
|
|
322
|
+
sign-in (`Login.tsx:401`, `dest = from.pathname + from.search`). **The Google
|
|
323
|
+
path does not:** `GoogleSuccessPage` ends with `navigate('/')` unconditionally
|
|
324
|
+
(`GoogleSuccess.tsx:54`), because Google's redirect round-trips through the
|
|
325
|
+
service and loses React Router's state.
|
|
326
|
+
|
|
327
|
+
This matters here more than anywhere: a Google-only user is the primary
|
|
328
|
+
beneficiary of the whole change, and today they would land on the dashboard
|
|
329
|
+
with the code gone. Fix: before `href={googleLoginUrl()}` (`Login.tsx:450`)
|
|
330
|
+
stash `dest` in `sessionStorage['rrepeat.returnTo']`; `GoogleSuccessPage`
|
|
331
|
+
reads and clears it and navigates there — after checking it is a same-origin
|
|
332
|
+
path (starts with a single `/`), so the stash can never become an open
|
|
333
|
+
redirect.
|
|
334
|
+
|
|
335
|
+
### 4.3 Sign-in history
|
|
336
|
+
|
|
337
|
+
`LoginHistory.tsx:10` types `method` as a four-member union and
|
|
338
|
+
`METHOD_LABEL` (`:31`) maps each to a label. Add `'device'` →
|
|
339
|
+
*"Runner sign-in (device code)"*. Without it the row renders the raw word.
|
|
340
|
+
|
|
341
|
+
### 4.4 What does not change
|
|
342
|
+
|
|
343
|
+
- **Caddy / CSP.** `try_files {path} /index.html` already serves any console
|
|
344
|
+
route, and `connect-src` already allows `https://api.bi2202.com`.
|
|
345
|
+
- **CORS.** `origin: config.webOrigin` (`index.ts:43`) is exactly the origin
|
|
346
|
+
`/activate` calls from.
|
|
347
|
+
- **Navigation.** No sidebar item; the page is reached by the link or by
|
|
348
|
+
typing the code. A one-line mention on `/guide/first-recording` where it
|
|
349
|
+
says `bir login` is enough.
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## 5. Runner — this repo
|
|
354
|
+
|
|
355
|
+
### 5.1 `src/auth/client.ts`
|
|
356
|
+
|
|
357
|
+
| Change | Detail |
|
|
358
|
+
|---|---|
|
|
359
|
+
| **Add** `startDeviceCode(authUrl, client, fetchImpl)` | `POST /auth/device/code`; returns the §3.1 shape. A `404` throws a typed `DeviceFlowUnsupported` so the caller can fall back (decision 4). |
|
|
360
|
+
| **Add** `pollDeviceToken(authUrl, deviceCode, {interval, expiresIn, fetchImpl, sleep, signal})` | The RFC loop. Honours `slow_down` by adding 5 s; stops on `access_denied`, `expired_token`, the deadline, or the signal. Returns an `AuthResponse`. `fetchImpl` and `sleep` are parameters so tests run in milliseconds. |
|
|
361
|
+
| **Add** `deviceLogin(authUrl, {openBrowser, log})` | Orchestrates: probe with `describeAuthService` (keep — the wrong-host mistake is still possible), mint, print the block from §1, open the browser (§2 decision 5), poll, `saveCredentials(toSession(res))`. |
|
|
362
|
+
| **Add** `openBrowser(url)` | `child_process.spawn`, detached, `stdio: 'ignore'`, never throws. No `shell: true`: the URL is one argument. On Windows the form is `cmd /c start "" <url>` — the empty string is `start`'s title argument and is required. |
|
|
363
|
+
| **Change** `authenticate()` | Delete the prompt branch at `:384-408`. After the refresh attempt it always returns `undefined` with `auth.no_session`. The TTY check at `:377` goes with it. |
|
|
364
|
+
| **Move** `prompt`, `promptPassword`, the `/auth/login` post | Into `legacyPasswordLogin(authUrl)`, called only by `bir login --password` and the 404 fallback. Delete with them in 0.3. |
|
|
365
|
+
| **Add** `logout(authUrl)` | Best-effort `POST /auth/logout {refreshToken}` from the cached session, then `clearCredentials()`. |
|
|
366
|
+
|
|
367
|
+
`loadCredentials`, `saveCredentials`, `clearCredentials`, `toSession`,
|
|
368
|
+
`normalizeAuthUrl`, `describeAuthService`, `AUTH_URL_HINT` are unchanged and
|
|
369
|
+
remain the whole contract with the rest of the runner.
|
|
370
|
+
|
|
371
|
+
### 5.2 `src/bin/bir.ts`
|
|
372
|
+
|
|
373
|
+
- `login` (`:1041-1059`): after the existing URL settling and service probe,
|
|
374
|
+
call `deviceLogin`. On `DeviceFlowUnsupported`, print one line —
|
|
375
|
+
*"this service does not offer browser sign-in yet; falling back to
|
|
376
|
+
email/password"* — and call `legacyPasswordLogin`.
|
|
377
|
+
- Flags in `parseArgs` (`:127-197`): `--password` (force legacy, prints a
|
|
378
|
+
deprecation line), `--no-browser`.
|
|
379
|
+
- `logout` (`:1061-1064`): call the new `logout()`; keep the same output line.
|
|
380
|
+
- Usage text (`:71-100`): `login sign in to the BaseIn service (opens your
|
|
381
|
+
browser; --no-browser to only print the link)`.
|
|
382
|
+
|
|
383
|
+
Output discipline stays as it is: the interactive lines go to **stderr**
|
|
384
|
+
(`process.stderr.write`, as the prompt does today), the final
|
|
385
|
+
`Signed in to … as ….` to stdout via `out()`.
|
|
386
|
+
|
|
387
|
+
### 5.3 `src/bin/bir-hooks.ts`
|
|
388
|
+
|
|
389
|
+
No code change at `:89` — but the *behaviour* changes: a `bir-hooks` started
|
|
390
|
+
with a TTY and no session no longer prompts; it logs
|
|
391
|
+
`recorder.disabled why="no BaseIn session — run bir login"` (already there at
|
|
392
|
+
`:91`) and runs as `NullRecorder`. `bir doctor` already turns that into
|
|
393
|
+
*"set BIR_AUTH_URL and run `bir login` in the terminal where you start
|
|
394
|
+
`bir-hooks`"* ([bir.ts:616-617](../src/bin/bir.ts#L616-L617)).
|
|
395
|
+
|
|
396
|
+
### 5.4 Installer scripts
|
|
397
|
+
|
|
398
|
+
`scripts/install-runner.sh:144-145` and `install-runner.ps1:153-154` call
|
|
399
|
+
`bir login` in the foreground and need **no code change** — the device flow
|
|
400
|
+
prints its link and code on whatever terminal the script has, and works over
|
|
401
|
+
SSH because the link can be opened anywhere. What changes is the prose around
|
|
402
|
+
them (the *"the password does not appear as you type"* notes) — §7.
|
|
403
|
+
|
|
404
|
+
### 5.5 Version
|
|
405
|
+
|
|
406
|
+
`0.2.0`: `authenticate()` stops prompting, which is a behaviour change other
|
|
407
|
+
processes can observe.
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## 6. Tests — runner
|
|
412
|
+
|
|
413
|
+
`test/config.test.ts` already unit-tests `describeAuthService` and
|
|
414
|
+
`normalizeAuthUrl` with a fake `fetch` (`:27`, `:125-163`). Add
|
|
415
|
+
`test/auth.test.ts` in the same style:
|
|
416
|
+
|
|
417
|
+
- `pollDeviceToken`: `pending → pending → ok` resolves to the session and
|
|
418
|
+
called `sleep(5000)` twice; `slow_down` bumps the next sleep to 10 s;
|
|
419
|
+
`access_denied` and `expired_token` reject with typed errors; the deadline
|
|
420
|
+
rejects even while the server keeps saying `pending`.
|
|
421
|
+
- `startDeviceCode`: a `404` throws `DeviceFlowUnsupported`; an HTML answer
|
|
422
|
+
produces the existing *"this URL is a website"* wording from `postJson`.
|
|
423
|
+
- `authenticate()` with no cache and a TTY **never** writes to stdin/stdout and
|
|
424
|
+
returns `undefined` (the regression test for decision 3).
|
|
425
|
+
- `saveCredentials` output: shape, and mode `0600` on POSIX.
|
|
426
|
+
|
|
427
|
+
`test/cli.test.ts` runs the built CLI as a process (`runBir`, `:36`). Add:
|
|
428
|
+
|
|
429
|
+
- `bir login --no-browser` against a local `createServer` stand-in (the pattern
|
|
430
|
+
in [recorder.test.ts:55-100](../test/recorder.test.ts#L55-L100)) that answers
|
|
431
|
+
`/health` with JSON, mints a code, says `pending` once, then `200`. Assert the
|
|
432
|
+
code appears on stderr, `Signed in to … as …` on stdout, and
|
|
433
|
+
`credentials.json` under the test's `BIR_HOME`.
|
|
434
|
+
- `bir login` against a stand-in whose `/auth/device/code` is `404` and whose
|
|
435
|
+
`/auth/login` succeeds — with stdin fed `email\npassword\n` — exercises the
|
|
436
|
+
fallback. Delete this test in 0.3 with the fallback.
|
|
437
|
+
- `bir logout` hits `/auth/logout` with the cached refresh token and removes
|
|
438
|
+
the file.
|
|
439
|
+
|
|
440
|
+
`test/smoke-replay.mjs` is unchanged: it pre-seeds credentials and never logs in.
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## 7. Documentation to change
|
|
445
|
+
|
|
446
|
+
Every place that shows or describes the prompt:
|
|
447
|
+
|
|
448
|
+
| File | What |
|
|
449
|
+
|---|---|
|
|
450
|
+
| [README.md](../README.md) `:54` | the `bir login # once` line gets the one-sentence description |
|
|
451
|
+
| [quickstart.md](quickstart.md) `:63-70` | the `Email:` / `Password:` transcript becomes the link-and-code block; delete *"The password does not appear as you type"* |
|
|
452
|
+
| [installInst.md](../installInst.md) Step 5 | same transcript change; the table row about `HTTP 405` stays |
|
|
453
|
+
| [installInstMAC.md](../installInstMAC.md) | same |
|
|
454
|
+
| [installRun.md](installRun.md) §5.1, §7 | §7 *Headless authentication* is rewritten: the device flow is the headless answer for **people** (approve from a phone); pre-seeded credentials remain for **service accounts**; runner tokens stay the follow-on |
|
|
455
|
+
| [calculatedReplayGuide.md](calculatedReplayGuide.md) §1 | the prerequisite table row |
|
|
456
|
+
| [my-first-sample.md](my-first-sample.md) §2 row 6, §4 | prerequisite row and Part B's `bir login # once` |
|
|
457
|
+
| `bir --help` | §5.2 |
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## 8. Compatibility and rollout
|
|
462
|
+
|
|
463
|
+
Ship in this order; each step is safe on its own.
|
|
464
|
+
|
|
465
|
+
1. **Service**: table, endpoints, limiter split, `login_events` method. Purely
|
|
466
|
+
additive — `/auth/login` is untouched, so every deployed runner keeps
|
|
467
|
+
working.
|
|
468
|
+
2. **Console**: `/activate`, the Google return-path fix, the history label.
|
|
469
|
+
3. **Runner 0.2.0**: device flow by default, `--password` and the 404 fallback
|
|
470
|
+
kept. A new runner against an old service degrades to today's behaviour with
|
|
471
|
+
one explanatory line.
|
|
472
|
+
4. **Runner 0.3.0**: remove `--password`, the fallback, and `legacyPasswordLogin`.
|
|
473
|
+
|
|
474
|
+
Old runner + new service: unchanged. New runner + old service: fallback (step 3).
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
## 9. Security
|
|
479
|
+
|
|
480
|
+
The device grant's own threat model is RFC 8628 §5; this is how each item lands
|
|
481
|
+
here.
|
|
482
|
+
|
|
483
|
+
| Concern | Answer |
|
|
484
|
+
|---|---|
|
|
485
|
+
| Guessing a `user_code` | 20⁸ ≈ 2.6 × 10¹⁰ codes, 10-minute life, approve limited to ~20 attempts / 15 min / IP (§3.5), constant-time compare. Not a threat. |
|
|
486
|
+
| Stealing a `device_code` | It never leaves the runner, travels only in a POST body over TLS, is stored hashed, and is single-use. It is not in any URL. |
|
|
487
|
+
| Phishing — *"enter this code on your account"* | The one real risk of every device flow. Mitigations: the page states *what* is asking (client, version, IP, age) and *who* it will sign in as, with the sentence *only approve if you just ran `bir login` yourself*; codes expire in 10 minutes; a wrong approval is one session, visible and revocable in Sign-in history. |
|
|
488
|
+
| Codes in logs | Handlers log the row `id` only; `req.log` never sees either code. |
|
|
489
|
+
| Tokens in URLs | None — an improvement on the Google handoff, which puts them in a fragment (`routes.ts:288-293`). |
|
|
490
|
+
| CSRF | Not applicable: bearer JSON API, no cookies on these routes. |
|
|
491
|
+
| Open redirect via the Google stash | The stored return path must start with a single `/` (§4.2). |
|
|
492
|
+
| Shell injection via `openBrowser` | `spawn` without a shell; the URL is one argument built by the service from `webOrigin` plus a code from a fixed alphabet. |
|
|
493
|
+
| A runner under a supervisor | Cannot start a sign-in any more (decision 3), so no unattended process ever holds an open device code. |
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## 10. Edge cases
|
|
498
|
+
|
|
499
|
+
- **Ctrl-C while waiting** — exit 130; the code stays pending until it expires;
|
|
500
|
+
nothing was written.
|
|
501
|
+
- **User approves after the CLI gave up** — the next `bir login` mints a fresh
|
|
502
|
+
code; the stale approval is consumed by nobody and pruned.
|
|
503
|
+
- **Two `bir login` on one machine** — independent codes; last writer wins
|
|
504
|
+
`credentials.json`. Same as today.
|
|
505
|
+
- **Approve twice** — the second is a `404`; the page says so.
|
|
506
|
+
- **Signed into the console as the wrong account** — the page names the account
|
|
507
|
+
it will sign the runner in as, before the button.
|
|
508
|
+
- **`BIR_HOME`, `BIR_AUTH_DISABLE=1`** — unchanged.
|
|
509
|
+
- **`describeAuthService` still runs first** — the wrong-host mistake now costs
|
|
510
|
+
nothing (no password is ever posted), but the diagnosis is still the useful
|
|
511
|
+
one.
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## 11. Follow-ons this plan deliberately leaves out
|
|
516
|
+
|
|
517
|
+
- **Runner tokens** for fleets (installRun.md §7 item 3): long-lived, scoped,
|
|
518
|
+
revocable credentials created in the console and passed as `BIR_TOKEN` /
|
|
519
|
+
`bir login --token`. This is the right answer for service accounts and it is
|
|
520
|
+
a separate design: token scopes, a *Tokens* section on the account page, and
|
|
521
|
+
a revocation story.
|
|
522
|
+
- **Naming CLI sessions.** A `client_name` column on `refresh_tokens` would let
|
|
523
|
+
a future *Sessions* page show "bir on DESKTOP-PQVT0H2 — last used 2 h ago
|
|
524
|
+
— [sign out]". Cheap to add now while the device flow already knows the name;
|
|
525
|
+
worth doing if that page is coming.
|
|
526
|
+
- **A QR code in the terminal** for the phone case. Zero-dependency ASCII QR
|
|
527
|
+
is ~150 lines; only worth it once the flow is in use.
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## 12. Alternatives considered
|
|
532
|
+
|
|
533
|
+
| Option | Why not |
|
|
534
|
+
|---|---|
|
|
535
|
+
| **Loopback redirect** (RFC 8252) | Fails exactly where `bir` runs: SSH, containers, runners with no browser. Needs a local listening port and a service-side redirect-URI allowlist. Could be added later as an *optimisation* of the same flow (auto-complete instead of poll) without changing the API. |
|
|
536
|
+
| **Paste a token from the console** | Works, but the user types a long secret into a terminal (history, transcripts), and "create a token" is a worse first-run than "approve". Kept as the shape of the fleet follow-on (§11), where it belongs. |
|
|
537
|
+
| **Keep the password, add "set a password" for Google users** | Forces a password on people who chose not to have one, and leaves problems 2–4 of §0 in place. |
|
|
538
|
+
| **Reverse device flow** — the console shows a code the user types into the terminal | Puts the low-entropy code on the *unauthenticated* side, which is the property the RFC's direction exists to avoid, and the terminal still has to poll or paste. |
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## 13. Open questions
|
|
543
|
+
|
|
544
|
+
1. Should sessions issued to a CLI have a different refresh lifetime than the
|
|
545
|
+
console's 30 days? (A runner is long-lived by nature; the console default is
|
|
546
|
+
probably right.)
|
|
547
|
+
2. Is `DEVICE_POLL_INTERVAL=5` right for the hosted service, or should it start
|
|
548
|
+
at 3 with the first `slow_down` moving it to 8?
|
|
549
|
+
3. Add the `client_name` column to `refresh_tokens` now (§11) or leave the
|
|
550
|
+
schema alone until a *Sessions* page is planned?
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## 14. Size
|
|
555
|
+
|
|
556
|
+
| Piece | Effort |
|
|
557
|
+
|---|---|
|
|
558
|
+
| Service: table, `device.ts`, four routes, limiter split, tests | ~1 day |
|
|
559
|
+
| Console: `/activate`, Google return path, history label | ~1 day |
|
|
560
|
+
| Runner: client functions, CLI flags, logout, tests | ~1 day |
|
|
561
|
+
| Docs, installer prose, manual acceptance run | ~½ day |
|
|
562
|
+
|
|
563
|
+
**Acceptance, by hand:** `bir logout` → `bir login` → approve in the console
|
|
564
|
+
signed in **with a Google account that has no password** → `bir doctor` says
|
|
565
|
+
`Recording: yes` → record one run → Sign-in history shows *Runner sign-in
|
|
566
|
+
(device code)* with the runner's IP → `bir logout` → the old refresh token now
|
|
567
|
+
gets `401` from `/auth/refresh`.
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## 15. What shipped
|
|
572
|
+
|
|
573
|
+
Implemented 2026-09-08. Runner **0.2.0**; service and console changes are
|
|
574
|
+
additive and went first, as §8 requires.
|
|
575
|
+
|
|
576
|
+
| Where | Files |
|
|
577
|
+
|---|---|
|
|
578
|
+
| Service | `src/auth/device.ts` (new) · `src/auth/routes.ts` (4 routes) · `src/db.ts` (`device_authorizations`) · `src/config.ts` (4 knobs) · `src/index.ts` (limiter split) · `src/middleware/loginLogger.ts` · `test/auth-device.test.ts` (new, 16 tests) |
|
|
579
|
+
| Console | `web/src/pages/Activate.tsx` (new) · `App.tsx` (route) · `api.ts` (return-path stash, device shapes) · `Login.tsx` + `GoogleSuccess.tsx` (the §4.2 fix) · `LoginHistory.tsx` · `FirstRecordingGuide.tsx` |
|
|
580
|
+
| Runner | `src/auth/client.ts` (device flow; `authenticate` made silent) · `src/bin/bir.ts` (login/logout, `--password`, `--no-browser`) · `src/proxy/session.ts` · `test/auth.test.ts` (new, 13 tests) · `test/cli.test.ts` (+5) |
|
|
581
|
+
| Docs | README · quickstart · installInst · installInstMAC · installRun §7 · calculatedReplayGuide · my-first-sample |
|
|
582
|
+
|
|
583
|
+
Green: service 202 tests, runner 253 (252 + 1 platform skip), console build,
|
|
584
|
+
`test:smoke`, and the §14 acceptance run against a **password-less account**.
|
|
585
|
+
|
|
586
|
+
### Two things that changed shape
|
|
587
|
+
|
|
588
|
+
**`nonInteractive` is gone rather than defaulted.** §2 decision 3 made
|
|
589
|
+
`authenticate()` silent, which left `AuthenticateOptions.nonInteractive` a flag
|
|
590
|
+
that could not change anything. A no-op option invites someone to toggle it and
|
|
591
|
+
expect a different outcome, so it was removed and `proxy/session.ts` updated —
|
|
592
|
+
the guarantee is now structural rather than requested.
|
|
593
|
+
|
|
594
|
+
**The password fallback cannot be driven by a pipe, so its test asserts the
|
|
595
|
+
decision.** `legacyPasswordLogin` refuses a non-TTY stdin, deliberately: a
|
|
596
|
+
password must not be readable from somewhere a script could have put it. The
|
|
597
|
+
CLI test therefore proves the 404 → fallback path is *taken* (and that the
|
|
598
|
+
browser flow was tried first), not that a piped password is accepted. Driving
|
|
599
|
+
the prompt itself would need a pseudo-terminal, for code being deleted next
|
|
600
|
+
release.
|
|
601
|
+
|
|
602
|
+
### One bug this found in existing code
|
|
603
|
+
|
|
604
|
+
The Google sign-in return path (§4.2) was already broken for every deep link,
|
|
605
|
+
not only `/activate` — `GoogleSuccessPage` navigated to `/` unconditionally, so
|
|
606
|
+
any guard-interrupted destination was lost for Google users. Fixed here because
|
|
607
|
+
a Google-only account is the primary beneficiary of the whole change.
|