feat(auth): the browser sign-in says whose account the terminal gets (#96)

* feat(auth): ask before signing a terminal in as whoever the browser is

`bdrive login` opened /auth/cli and the browser bounced straight back with a
code. Whoever the browser happened to be signed in as is who the terminal
became — silently. That is frequently not the account the user meant: a
personal login left open in the default browser, a teammate's session on a
shared machine. The mistake surfaces much later, as a synced folder full of
commits authored by the wrong person, which is far more work to undo than one
click would have been.

The device flow already got this right in #83 — it names the account, offers
to switch, and says what approving grants. The browser flow said nothing at
all, for the same outcome: a token that acts as you.

So /auth/cli now confirms first. GET renders the page (who you would be
signing in as, a Switch account link that comes back to this same pending
sign-in, what is asking, and where it is waiting); POST is what mints the
code and redirects to the loopback listener. A GET therefore grants nothing,
so a link someone else got you to open can no longer mint a code on your
behalf.

whoBlock loses its pendingGrant parameter and renders only the identity half.
What is asking differs per flow — a device has a name and an OS, a CLI on this
computer has a loopback port — so each page now renders its own rows through a
small helper instead of whoBlock pretending to a shape neither quite fits.

The CLI's own wording follows: "waiting for you to approve the sign-in in your
browser", since being signed in already is no longer the whole story.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* docs: the browser sign-in confirms first, and says whose account it grants

README, the CLI reference, and the self-hosting auth page all described the
old behaviour — sign in and the page bounces a code straight to the terminal.
They also read as though only `--device` had an approval step. Both flows now
confirm; say so, and say why it matters (the browser session is often not the
account the user meant).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* feat(auth): one web step for a first sign-in, not two

The confirmation page fixed the wrong-account problem and created a smaller
one: a user with no browser session now saw two pages on their first
`bdrive init` — sign in, then approve — where the sign-in had already settled
the only question the second page asks.

So authenticating *for* a pending CLI sign-in now counts as approving it. The
login and signup pages carry a line saying a terminal is waiting and that the
account used here is the one it will act as, which is where that consent is
made informed; reaching the callback then needs no second click.

The marker is server-side, bound to the exact pending sign-in, single use, and
two minutes long, so it can only ever skip the page it was granted for and only
once. It cannot be forged: setting it requires authenticating as that account,
and anyone who could do that could click Approve anyway.

An existing session still gets the page — that is the case where the browser
may be signed in as someone the user did not intend, which is the whole reason
it exists. Net effect: exactly one web interaction either way.

The device flow keeps its explicit approval. Its page names a machine that
isn't this one, along with the OS and address it came from — information no
login form can convey, about a grant to somewhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(auth): keep `bdrive login` on one line in the approval hint

It wrapped mid-phrase into two separate code boxes, which reads as two
commands rather than one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* refactor(auth): one approval page for both sign-in flows

The two flows ask the same question — shall this thing act as you? — and had
two copies of the page asking it, differing in three strings. They had already
started drifting: a wrapping fix went into the CLI copy only, leaving the
device page able to break `bdrive login --device` across two code boxes. A
page whose whole purpose is consistent disclosure is a bad place to keep two
of everything.

So pageAuth owns the shape (session check, redirect to login, whoBlock, rows,
the Approve form, the note) and each flow supplies an authRequest describing
what differs: how the request is identified, what is asking, and what
approving does.

Two asymmetries are now explicit rather than accidental. freshAuthSkips is
true only for the local flow — signing in and approving are the same act when
the terminal is on this machine, and are not when the token goes to another
one. live() reports whether the request still exists, because the device
flow's link expires while the CLI flow carries its whole request in the URL
and has nothing to expire.

detail is a function, not a slice: the device rows come off the pending grant,
which only exists after live() has found it.

No test changed. The pages render byte-identically — same sha256 for all three
CLI screenshots before and after — and the device flow was driven end to end
against a real hub, approving a real `bdrive login --device`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* feat(auth): both sign-in flows always ask you to approve

Consistency between the two flows is worth more than the click it saves.
Letting a sign-in count as its own approval made the local flow one step and
the device flow two, so the same product asked for consent in two different
shapes depending on which machine you were on — and the shape that skipped it
was the one where the page had something to tell you.

So the fresh-auth marker is gone: sign in, then approve, on both flows. That
drops a map, two methods, a descriptor field, and a branch in pageAuth — the
unified handler now has exactly one path through it.

A first `bdrive init` on a fresh machine is two web pages again. That is the
deliberate trade: the approval page is where a user sees which account a
machine is about to act as, and nothing shortcuts it.

The sign-in page keeps the line saying a terminal is waiting. It no longer
carries the consent — the next page does — so it is there to explain why a
password prompt appeared at all.

TestBothFlowsAlwaysAskToApprove replaces the one-step test and runs the same
assertions over both flows as subtests: no session sends you to sign in
carrying the request, signing in returns to the request without granting, the
approval page is there every time, and only the POST grants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-07-30 21:14:13 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 118abc67e2
commit b1c0bba415
7 changed files with 334 additions and 63 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
| Command | Description |
|---|---|
| `bdrive login [server-url]` | Sign this device in. Browser flow; `--device` forces the approval-link flow, and shells without a TTY (agents, CI, SSH) fall back to it automatically. Default server is beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host. Switch hubs with `bdrive login <new-url>`. `--status` shows the current server and account |
| `bdrive login [server-url]` | Sign this device in. Browser flow — the page names the account this terminal would act as and lets you switch before approving; `--device` forces the approval-link flow, and shells without a TTY (agents, CI, SSH) fall back to it automatically. Default server is beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host. Switch hubs with `bdrive login <new-url>`. `--status` shows the current server and account |
| `bdrive logout` | Sign this device out — clear the saved token and account. `--forget` also drops the remembered server |
| `bdrive init [folder]` | Create or connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY; flags (`--name`, `--project`, `--server`, `--only`, `--yes`) for scripts. Also registers agent sync hooks for detected platforms (`--no-hooks` skips them) and a login item so sync resumes after a reboot (`--no-autostart` skips), and prints the project's hub link. Re-run to resume |
| `bdrive resume` | Restart the sync daemon for every project on this device that isn't paused — after a reboot, a crash, or a manual kill. Idempotent, so running it twice is harmless. This is what the login item runs |
@@ -58,10 +58,19 @@ server-config-owned, so a browser session can never widen who gets in.
## Device sign-in
Both sign-in flows end the same way: **an approval page you have to click.** It
names the account the machine would act as, because that is what approving
grants — whichever account the browser is signed in as is the one the terminal
becomes, and it is often not the one you meant (a personal login left open, a
teammate's session on a shared machine). **Switch account** signs you out and
returns to the same pending sign-in, so picking the right one costs a click
rather than a re-run. Approval is a POST from that page, so merely opening a
sign-in link grants nothing.
`bdrive login <url>` opens the server's sign-in page in a browser — sign up
right there if needed. When the user signs in, the page bounces a one-time code
to the CLI's loopback listener and the terminal finishes on its own, storing a
long-lived per-device token that is revocable server-side.
right there if needed — and lands on that approval page. Approving bounces a
one-time code to the CLI's loopback listener and the terminal finishes on its
own, storing a long-lived per-device token that is revocable server-side.
On headless or SSH machines login falls back to the device-code flow
automatically (no TTY, or no browser can open): it prints one approval link to