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