package webapp import ( "encoding/json" "fmt" "html" "io" "net/http" "net/url" "strings" "sync" "time" ) // CLIAuth is the CLI-facing half of signing in, whole: the loopback browser // flow (/auth/cli → one-time code → /api/auth/exchange), the headless device // flow (/api/auth/device/start → approval link → /api/auth/device/poll), and // the approval page both of them show. // // It is its own type rather than methods on an AuthProvider because this half // of the protocol is identical no matter where the accounts live: `bdrive // login` POSTs fixed paths and expects fixed JSON, so a provider differs only // in who the browser session is and how a device token is minted — the two // hooks below. The managed hub's provider used to carry its own copy of all // of this, and the copy drifted: months after the OSS flow moved to a single // approval link that names the device, the copy was still printing a // four-byte code to retype into a text box. One implementation, every // provider, nothing to keep in sync. type CLIAuth struct { session func(*http.Request) (User, bool) issue func(w http.ResponseWriter, userID, device string) // Ephemeral single-use state; a server restart just cancels pending // logins. mu sync.Mutex pending map[string]cliGrant } // cliGrant is one pending sign-in: a browser-flow code (granted at birth, // consumed by the exchange) or a device-flow link (granted when its approval // page is POSTed, consumed by the poll). type cliGrant struct { kind string // "code" (browser callback) | "device" (poll flow) user string // set once granted device string // device flow: requested device name os string // device flow: requested device's OS ip string // device flow: where the request came from, as the server saw it granted bool expires time.Time } // NewCLIAuth wires the two provider-specific pieces. session resolves the // browser session — cookie only, never a Bearer token, or a device token // could approve the next device. issue writes the CLI's {token, user} // response for an approved grant. func NewCLIAuth(session func(*http.Request) (User, bool), issue func(w http.ResponseWriter, userID, device string)) *CLIAuth { return &CLIAuth{session: session, issue: issue, pending: make(map[string]cliGrant)} } // Register mounts the paths `bdrive login` knows. They are fixed: an older // CLI on a newer hub must still find them. func (c *CLIAuth) Register(mux *http.ServeMux) { mux.HandleFunc("GET /auth/cli", c.pageCLI) mux.HandleFunc("POST /auth/cli", c.pageCLI) mux.HandleFunc("GET /auth/device/{token}", c.pageDevice) mux.HandleFunc("POST /auth/device/{token}", c.pageDevice) mux.HandleFunc("GET /auth/device", c.pageDeviceLegacy) mux.HandleFunc("POST /api/auth/exchange", c.apiExchange) mux.HandleFunc("POST /api/auth/device/start", c.apiDeviceStart) mux.HandleFunc("POST /api/auth/device/poll", c.apiDevicePoll) } // ---- grants ---- func (c *CLIAuth) newGrant(g cliGrant, ttl time.Duration) string { id := randHex(16) g.expires = time.Now().Add(ttl) c.mu.Lock() c.pending[id] = g c.mu.Unlock() return id } // take consumes a grant; peek reads one without consuming (device polling). func (c *CLIAuth) take(kind, id string) (cliGrant, bool) { c.mu.Lock() defer c.mu.Unlock() g, ok := c.pending[id] if !ok || g.kind != kind || time.Now().After(g.expires) { delete(c.pending, id) return cliGrant{}, false } delete(c.pending, id) return g, true } func (c *CLIAuth) peek(kind, id string) (cliGrant, bool) { c.mu.Lock() defer c.mu.Unlock() g, ok := c.pending[id] if !ok || g.kind != kind || time.Now().After(g.expires) { return cliGrant{}, false } return g, true } func (c *CLIAuth) approveDevice(id, userID string) bool { c.mu.Lock() defer c.mu.Unlock() g, ok := c.pending[id] if !ok || g.kind != "device" || time.Now().After(g.expires) { return false } g.user, g.granted = userID, true c.pending[id] = g return true } // ---- the approval page ---- // 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) } // pageAuth is the approval page both sign-in flows share. func (c *CLIAuth) pageAuth(w http.ResponseWriter, r *http.Request, req authRequest) { user, ok := c.session(r) if !ok { http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) return } if req.live != nil && !req.live() { return } if r.Method == http.MethodPost { req.approve(user) return } authPage(w, req.title, fmt.Sprintf(`
%s
%s%s%s
`, html.EscapeString(req.lede), whoBlock(user, r.URL.RequestURI()), rows(req.detail()...), req.note)) } // 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. func (c *CLIAuth) 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 } c.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 := c.newGrant(cliGrant{kind: "code", user: user.ID, granted: 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 (c *CLIAuth) pageDevice(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
var g cliGrant
expired := func(when string) {
authPage(w, "Link expired", `This sign-in link `+when+`.
Run bdrive login --device again for a fresh one.
%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))) }, }) } // pageDeviceLegacy forwards the pre-0.13 link shape (/auth/device?code=…), // which older CLIs still print, to the path form. func (c *CLIAuth) pageDeviceLegacy(w http.ResponseWriter, r *http.Request) { code := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("code"))) if code == "" { authPage(w, "Connect a device", `Run bdrive login --device on the machine you want to connect; it prints a link to open here.