From b1c0bba415f5601ed67f5696ec79ac4804ec1675 Mon Sep 17 00:00:00 2001 From: "Snow Lee (Sungwon)" Date: Thu, 30 Jul 2026 21:14:13 +0900 Subject: [PATCH] feat(auth): the browser sign-in says whose account the terminal gets (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) 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) 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) 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) 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) 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) Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 --------- Co-authored-by: Claude Opus 5 (1M context) --- README.md | 2 +- cmd/bdrive/login.go | 2 +- internal/syncer/org_authz_test.go | 4 +- internal/webapp/auth_test.go | 163 +++++++++++++- internal/webapp/authlocal.go | 209 +++++++++++++----- web/docs/src/content/docs/reference/cli.md | 2 +- .../docs/self-hosting/authentication.md | 15 +- 7 files changed, 334 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index d468d75..82203cb 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ hub's own storage, never something a syncing client points at directly: | Command | Description | |---|---| -| `bdrive login [server-url]` | Sign this device in (browser flow; `--device` forces the approval-link flow, and shells without a TTY fall back to it automatically; default server beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host). Switch hubs with `bdrive login ` | +| `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 fall back to it automatically; default server beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host). Switch hubs with `bdrive login ` | | `bdrive logout` | Sign this device out — clear the saved token/account (`--forget` also drops the remembered server) | | `bdrive init [folder]` | Create/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; registers agent sync hooks and the login autostart in each platform's user config (`--no-hooks` skips the hooks), prints the project 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; this is what the login agent runs | diff --git a/cmd/bdrive/login.go b/cmd/bdrive/login.go index 2472678..c17e107 100644 --- a/cmd/bdrive/login.go +++ b/cmd/bdrive/login.go @@ -297,7 +297,7 @@ func browserLogin(server, loginPath string) (string, serverUser, error) { // a URL the user pastes elsewhere would dead-end — use the code flow. return "", serverUser{}, errNoBrowser } - fmt.Println("waiting for the browser sign-in… (no browser here? Ctrl-C and run `bdrive login --device`)") + fmt.Println("waiting for you to approve the sign-in in your browser… (no browser here? Ctrl-C and run `bdrive login --device`)") select { case code := <-codeCh: diff --git a/internal/syncer/org_authz_test.go b/internal/syncer/org_authz_test.go index 8da68c4..61bc5c8 100644 --- a/internal/syncer/org_authz_test.go +++ b/internal/syncer/org_authz_test.go @@ -87,7 +87,9 @@ func signupDeviceToken(t *testing.T, ts *httptest.Server, email, name string) st t.Fatalf("signup(%s) set no session cookie: %d", email, resp.StatusCode) } - req, _ := http.NewRequest("GET", ts.URL+"/auth/cli?redirect="+url.QueryEscape("http://127.0.0.1:1/cb")+"&state=s", nil) + // POST, not GET: /auth/cli shows a confirmation page first, and approving + // it is what mints the code — the same click a user makes in the browser. + req, _ := http.NewRequest("POST", ts.URL+"/auth/cli?redirect="+url.QueryEscape("http://127.0.0.1:1/cb")+"&state=s", nil) req.AddCookie(session) resp, err = jarless.Do(req) if err != nil { diff --git a/internal/webapp/auth_test.go b/internal/webapp/auth_test.go index 38025e6..b16b77d 100644 --- a/internal/webapp/auth_test.go +++ b/internal/webapp/auth_test.go @@ -3,11 +3,13 @@ package webapp import ( "context" "encoding/json" + "html" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" + "regexp" "strings" "testing" @@ -223,8 +225,24 @@ func TestCLICallbackFlow(t *testing.T) { t.Fatalf("cli without session: %d %s", rec.Code, rec.Header().Get("Location")) } - // with a session: redirect back to the loopback with code+state - req = httptest.NewRequest("GET", "/auth/cli?redirect="+url.QueryEscape("http://127.0.0.1:9999/callback")+"&state=s1", nil) + // with a session, a GET asks first — it names the account the terminal + // would act as and offers to switch, and grants nothing on its own. + cliURL := "/auth/cli?redirect=" + url.QueryEscape("http://127.0.0.1:9999/callback") + "&state=s1" + req = httptest.NewRequest("GET", cliURL, nil) + req.AddCookie(cookie) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("cli confirmation page: %d, want 200", rec.Code) + } + for _, want := range []string{"cli@x.io", "Switch account", "127.0.0.1:9999", "Approve"} { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("confirmation page missing %q:\n%s", want, rec.Body) + } + } + + // approving posts back to the same URL and lands on the loopback listener + req = httptest.NewRequest("POST", cliURL, nil) req.AddCookie(cookie) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) @@ -569,3 +587,144 @@ func TestConfigAnalyticsSeam(t *testing.T) { t.Fatalf("analytics host override = %s", got) } } + +// The reason the CLI flow confirms at all: the browser's session is often not +// the account the user meant the terminal to act as. Switching must come back +// to the same pending sign-in rather than dumping them on the home page. +func TestCLILoginSwitchAccount(t *testing.T) { + srv, _, _ := authHub(t, true) + h := srv.Handler() + personal := signupAndSession(t, h, "me@personal.io", "Me", "password1") + + cliURL := "/auth/cli?redirect=" + url.QueryEscape("http://127.0.0.1:9999/callback") + "&state=s1" + + // the page offers a way out, carrying this sign-in along + req := httptest.NewRequest("GET", cliURL, nil) + req.AddCookie(personal) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + swap := regexp.MustCompile(`href="(/auth/logout\?next=[^"]+)"`).FindStringSubmatch(rec.Body.String()) + if swap == nil { + t.Fatalf("no switch-account link on the confirmation page:\n%s", rec.Body) + } + + // following it drops the session and heads for a fresh login + req = httptest.NewRequest("GET", html.UnescapeString(swap[1]), nil) + req.AddCookie(personal) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + loc := rec.Header().Get("Location") + if rec.Code != http.StatusSeeOther || !strings.HasPrefix(loc, "/auth/login?next=") { + t.Fatalf("switch account = %d %s", rec.Code, loc) + } + next, err := url.QueryUnescape(strings.TrimPrefix(loc, "/auth/login?next=")) + if err != nil || next != cliURL { + t.Fatalf("switch account loses the pending sign-in: next=%q want %q", next, cliURL) + } + + // signing in as someone else returns to the same confirmation, now naming them + work := signupAndSession(t, h, "me@work.io", "Me At Work", "password2") + req = httptest.NewRequest("GET", next, nil) + req.AddCookie(work) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "me@work.io") { + t.Fatalf("after switching, confirmation does not name the new account: %d\n%s", rec.Code, rec.Body) + } + if strings.Contains(rec.Body.String(), "me@personal.io") { + t.Fatalf("confirmation still shows the old account:\n%s", rec.Body) + } + + // and approving as them grants to them + req = httptest.NewRequest("POST", next, nil) + req.AddCookie(work) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("approve after switch: %d", rec.Code) + } + cb, _ := url.Parse(rec.Header().Get("Location")) + out := do(t, h, "POST", "/api/auth/exchange", map[string]string{"code": cb.Query().Get("code"), "device": "laptop"}) + if !strings.Contains(out.Body.String(), "me@work.io") { + t.Fatalf("token issued to the wrong account: %s", out.Body) + } +} + +// Both sign-in flows must behave identically for someone with no web session: +// sign in, then explicitly approve. Nothing may shortcut the approval — that +// page is where the user sees which account a machine is about to act as. +func TestBothFlowsAlwaysAskToApprove(t *testing.T) { + srv, auth, _ := authHub(t, true) + h := srv.Handler() + signupAndSession(t, h, "first@x.io", "First", "password1") + + // a pending device request, so both flows have something real to approve + rec := do(t, h, "POST", "/api/auth/device/start", map[string]string{"device": "laptop", "os": "linux"}) + var start struct{ Code string } + if err := json.Unmarshal(rec.Body.Bytes(), &start); err != nil || start.Code == "" { + t.Fatalf("device start: %s", rec.Body) + } + + for _, tc := range []struct{ name, url string }{ + {"cli", "/auth/cli?redirect=" + url.QueryEscape("http://127.0.0.1:9999/callback") + "&state=s1"}, + {"device", "/auth/device/" + start.Code}, + } { + t.Run(tc.name, func(t *testing.T) { + // no session: sent to sign in, carrying this request + req := httptest.NewRequest("GET", tc.url, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + loginURL := rec.Header().Get("Location") + if rec.Code != http.StatusSeeOther || !strings.HasPrefix(loginURL, "/auth/login?next=") { + t.Fatalf("without a session = %d %s", rec.Code, loginURL) + } + + // signing in returns to the request and must NOT grant on the way + form := url.Values{"email": {"first@x.io"}, "password": {"password1"}, "next": {tc.url}} + req = httptest.NewRequest("POST", loginURL, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != tc.url { + t.Fatalf("login should return to the pending request: %d %s", rec.Code, rec.Header().Get("Location")) + } + var session *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == sessionCookie { + session = c + } + } + if session == nil { + t.Fatal("login started no session") + } + + // and there the approval page waits — every time, for both flows + req = httptest.NewRequest("GET", tc.url, nil) + req.AddCookie(session) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("signing in must not shortcut the approval: %d %s", rec.Code, rec.Header().Get("Location")) + } + for _, want := range []string{"first@x.io", "Switch account", "Approve"} { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("approval page missing %q:\n%s", want, rec.Body) + } + } + + // only the POST grants + req = httptest.NewRequest("POST", tc.url, nil) + req.AddCookie(session) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if tc.name == "cli" { + if rec.Code != http.StatusSeeOther { + t.Fatalf("approve: %d", rec.Code) + } + } else if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Device connected") { + t.Fatalf("approve: %d\n%s", rec.Code, rec.Body) + } + }) + } + _ = auth +} diff --git a/internal/webapp/authlocal.go b/internal/webapp/authlocal.go index 57ec387..4999488 100644 --- a/internal/webapp/authlocal.go +++ b/internal/webapp/authlocal.go @@ -518,6 +518,7 @@ func (a *BuiltinAuth) Register(mux *http.ServeMux) { mux.HandleFunc("POST /auth/signup", a.pageSignup) mux.HandleFunc("GET /auth/logout", a.pageLogout) mux.HandleFunc("GET /auth/cli", a.pageCLI) + mux.HandleFunc("POST /auth/cli", a.pageCLI) mux.HandleFunc("GET /auth/device/{token}", a.pageDevice) mux.HandleFunc("POST /auth/device/{token}", a.pageDevice) mux.HandleFunc("GET /auth/device", a.pageDeviceLegacy) @@ -540,6 +541,9 @@ func (a *BuiltinAuth) sessionUser(r *http.Request) (User, bool) { return User{}, false } +// cliSignIn reports whether a next URL is a pending CLI sign-in. +func cliSignIn(next string) bool { return strings.HasPrefix(next, "/auth/cli?") } + func (a *BuiltinAuth) startSession(w http.ResponseWriter, userID string) error { tok, err := a.issueToken(userID, "web-session") if err != nil { @@ -561,6 +565,18 @@ func inviteBanner(next string) string { return `

You've been invited to a team. Sign in (or sign up) to accept.

` } +// cliBanner says what a sign-in reached from `bdrive login` is for, so the form +// is not a bare password prompt appearing for no visible reason. Approving is +// still its own step on the next page — this only explains why signing in is +// being asked for at all. +func cliBanner(next string) string { + if !cliSignIn(next) { + return "" + } + return `

A terminal on this computer is waiting to sign in. ` + + `The account you use here is the one it will act as.

` +} + // safeNext keeps post-login redirects on this site. func safeNext(next string) string { if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { @@ -734,7 +750,7 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) { if a.Brand != "" { brand = `

` + html.EscapeString(a.Brand) + `

` } - authPage(w, "Sign in", brand+inviteBanner(next)+fmt.Sprintf(`
%s%s%s
+ authPage(w, "Sign in", brand+inviteBanner(next)+cliBanner(next)+fmt.Sprintf(`
%s%s%s
%s

Forgot password?

`, url.QueryEscape(next), field("Email", "email", "email", r.FormValue("email")), @@ -800,7 +816,7 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) { if a.Brand != "" { brand = `

` + html.EscapeString(a.Brand) + `

` } - authPage(w, "Create account", brand+inviteBanner(next)+fmt.Sprintf(`
%s%s%s%s%s
+ authPage(w, "Create account", brand+inviteBanner(next)+cliBanner(next)+fmt.Sprintf(`
%s%s%s%s%s

Have an account? Sign in

`, url.QueryEscape(next), field("Name", "name", "text", r.FormValue("name")), @@ -825,69 +841,144 @@ func (a *BuiltinAuth) pageLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, dest, http.StatusSeeOther) } -// pageCLI completes `bdrive login`: once the browser has a session, mint a -// one-time code and bounce it to the CLI's loopback listener. Redirects are -// restricted to loopback addresses so the code can't be sent anywhere else. -func (a *BuiltinAuth) pageCLI(w http.ResponseWriter, r *http.Request) { - redirect := r.URL.Query().Get("redirect") - state := r.URL.Query().Get("state") - u, err := url.Parse(redirect) - if err != nil || (u.Scheme != "http") || (u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" && u.Hostname() != "::1") { - http.Error(w, "invalid redirect (must be a loopback URL)", http.StatusBadRequest) - return - } - user, ok := a.sessionUser(r) - if !ok { - http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.String()), http.StatusSeeOther) - return - } - code := a.newGrant("code", user.ID, "", true, time.Minute) - q := u.Query() - q.Set("code", code) - q.Set("state", state) - u.RawQuery = q.Encode() - http.Redirect(w, r, u.String(), http.StatusSeeOther) +// pageCLI completes `bdrive login`: confirm who the terminal will act as, then +// mint a one-time code and bounce it to the CLI's loopback listener. Redirects +// are restricted to loopback addresses so the code can't be sent anywhere else. +// +// The confirmation is the point, not ceremony. Whoever the browser happens to +// be signed in as is who the terminal becomes, and that is frequently not the +// account the user meant — a personal login left open, a teammate's session on +// a shared machine. Granting silently means the mistake surfaces later, as a +// synced folder full of commits authored by the wrong person, which is far +// more work to undo than one click now. +// +// It also means a GET no longer grants anything, so a link someone else got +// you to open can't mint a code on your behalf. +// authRequest describes a pending sign-in to pageAuth. Both flows ask the user +// the same question — "shall this thing act as you?" — and differ only in how +// the request is identified, what is asking, and what approving does. Keeping +// that difference in data rather than in two copies of the page is what stops +// the two from drifting apart, which matters here: a flow whose disclosure +// quietly falls behind the other's is the failure mode this page exists to +// prevent. +type authRequest struct { + title string // heading + lede string // one line naming what is asking (plain text) + note string // when approving is the right call — trusted markup + + // detail is what is asking, in detail. A function because the device flow + // reads it off the pending grant, which only exists once live() has found + // it — so it must be evaluated at render time, not at call time. + detail func() [][2]string + + // live, when set, runs once the session is known and before anything is + // shown or granted, reporting whether the request still exists — having + // already written its own explanation when it doesn't. The device flow's + // link expires; the CLI flow carries its whole request in the URL and has + // nothing to expire. + live func() bool + + // approve performs the grant and writes the response. + approve func(user User) } -// pageDevice is the headless-login approval page, reached by opening the link -// `bdrive login` printed: the token lives in the path, so there is no code to -// read off one screen and type into another. It names the account the device -// will act as (with a way to switch), and what is asking, because approving -// hands that machine a token that acts as you. -func (a *BuiltinAuth) pageDevice(w http.ResponseWriter, r *http.Request) { - token := r.PathValue("token") +// pageAuth is the approval page both sign-in flows share. +func (a *BuiltinAuth) pageAuth(w http.ResponseWriter, r *http.Request, req authRequest) { user, ok := a.sessionUser(r) if !ok { http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) return } - g, ok := a.peekGrant("device", token) - if !ok { - authPage(w, "Link expired", `

This sign-in link is invalid, already used, or older than 10 minutes.

-

Run bdrive login --device again for a fresh one.

`) + if req.live != nil && !req.live() { return } if r.Method == http.MethodPost { - if !a.grantDevice(token, user.ID) { - authPage(w, "Link expired", `

This sign-in link expired while the page was open.

-

Run bdrive login --device again for a fresh one.

`) - return - } - authPage(w, "Device connected", fmt.Sprintf(`

%s can now sync as %s.

-

You can close this tab — the terminal finishes on its own.

`, - html.EscapeString(orDash(g.device)), html.EscapeString(user.Email))) + req.approve(user) return } - authPage(w, "Connect a device", fmt.Sprintf(`

A device is asking to sign in to BearDrive.

-%s + authPage(w, req.title, fmt.Sprintf(`

%s

+%s%s
-

Approve this only if you just started a sign-in on that machine.

`, - whoBlock(user, g, r.URL.RequestURI()))) +

%s

`, + html.EscapeString(req.lede), whoBlock(user, r.URL.RequestURI()), rows(req.detail()...), req.note)) } -// whoBlock renders the two things the approver needs: who they'd be granting -// as (with an escape hatch back to this same page), and what is asking. -func whoBlock(user User, g pendingGrant, back string) string { +func (a *BuiltinAuth) pageCLI(w http.ResponseWriter, r *http.Request) { + u, err := url.Parse(r.URL.Query().Get("redirect")) + if err != nil || (u.Scheme != "http") || (u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" && u.Hostname() != "::1") { + http.Error(w, "invalid redirect (must be a loopback URL)", http.StatusBadRequest) + return + } + a.pageAuth(w, r, authRequest{ + title: "Sign in on this computer", + lede: "A terminal on this computer is asking to sign in to BearDrive.", + detail: func() [][2]string { + return [][2]string{{"Application", "bdrive command line"}, {"Waiting at", u.Host}} + }, + note: `Approve this only if you just ran ` + + `bdrive login yourself.`, + approve: func(user User) { + code := a.newGrant("code", user.ID, "", true, time.Minute) + q := u.Query() + q.Set("code", code) + q.Set("state", r.URL.Query().Get("state")) + u.RawQuery = q.Encode() + http.Redirect(w, r, u.String(), http.StatusSeeOther) + }, + }) +} + +// pageDevice is the headless-login approval page, reached by opening the link +// `bdrive login` printed: the token lives in the path, so there is no code to +// read off one screen and type into another. +// +// Unlike the local flow this one never skips the page. The machine being +// granted is not the one reading this, so the account, the device name, its OS +// and its address are the only things standing between an approval and a +// stranger's pending link. +func (a *BuiltinAuth) pageDevice(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + var g pendingGrant + expired := func(when string) { + authPage(w, "Link expired", `

This sign-in link `+when+`.

+

Run bdrive login --device again for a fresh one.

`) + } + a.pageAuth(w, r, authRequest{ + title: "Connect a device", + lede: "A device is asking to sign in to BearDrive.", + note: "Approve this only if you just started a sign-in on that machine.", + detail: func() [][2]string { + return [][2]string{{"Device", g.device}, {"System", g.os}, {"Address", g.ip}} + }, + live: func() bool { + var ok bool + if g, ok = a.peekGrant("device", token); !ok { + expired("is invalid, already used, or older than 10 minutes") + return false + } + return true + }, + approve: func(user User) { + if !a.grantDevice(token, user.ID) { + expired("expired while the page was open") + return + } + authPage(w, "Device connected", fmt.Sprintf(`

%s can now sync as %s.

+

You can close this tab — the terminal finishes on its own.

`, + html.EscapeString(orDash(g.device)), html.EscapeString(user.Email))) + }, + }) +} + +// whoBlock renders who the approver would be granting as, with an escape hatch +// back to this same page. Approving on either flow hands a machine a token +// that acts as you, so "as whom" is the question worth answering loudest — and +// the browser's session is often not the account the user meant to use. +// +// 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 renders its own rows rather +// than this pretending to a shape neither quite fits. +func whoBlock(user User, back string) string { name := user.Name if name == "" { name = user.Email @@ -895,10 +986,20 @@ func whoBlock(user User, g pendingGrant, back string) string { return fmt.Sprintf(`
Signing in as%s%s
Switch account -
-
Device
%s
System
%s
Address
%s
`, - html.EscapeString(name), html.EscapeString(user.Email), url.QueryEscape(safeNext(back)), - html.EscapeString(orDash(g.device)), html.EscapeString(orDash(g.os)), html.EscapeString(orDash(g.ip))) +`, + html.EscapeString(name), html.EscapeString(user.Email), url.QueryEscape(safeNext(back))) +} + +// rows renders a label/value list, dashing out the blanks. +func rows(pairs ...[2]string) string { + var b strings.Builder + b.WriteString(`
`) + for _, p := range pairs { + fmt.Fprintf(&b, "
%s
%s
", + html.EscapeString(p[0]), html.EscapeString(orDash(p[1]))) + } + b.WriteString(`
`) + return b.String() } func orDash(s string) string { diff --git a/web/docs/src/content/docs/reference/cli.md b/web/docs/src/content/docs/reference/cli.md index 8f0f5a9..a3f2d33 100644 --- a/web/docs/src/content/docs/reference/cli.md +++ b/web/docs/src/content/docs/reference/cli.md @@ -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 `. `--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 `. `--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 | diff --git a/web/docs/src/content/docs/self-hosting/authentication.md b/web/docs/src/content/docs/self-hosting/authentication.md index fb69693..2b00d3c 100644 --- a/web/docs/src/content/docs/self-hosting/authentication.md +++ b/web/docs/src/content/docs/self-hosting/authentication.md @@ -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 ` 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