Files
94091a8795 fix(hub): device binding is a provider contract, not a BuiltinAuth field (#147)
ownJournal refuses a journal write unless the device id is bound to the
caller's account, for EVERY provider — it asks only whether s.Devices is
nil. The only thing that creates that binding is DeviceRegistry.Bind, whose
only caller is BuiltinAuth.finishLogin, and that hook was wired behind
`if a, ok := s.Auth.(*BuiltinAuth); ok`.

So a hub running a managed AuthProvider — the deployment the seam exists
for — bound nothing, ever, and refused every journal push from every device
forever. Everything around it read healthy: /api/auth/me answered, project
permissions said write, and blobs (content-addressed, so ownerless) uploaded
fine. Only the journal PUT died. Signing in again could not help, because
signing in was the step that was supposed to bind.

UseDeviceBinder moves the hook onto the AuthProvider interface — a breaking
change for an out-of-tree provider, deliberately, so one that ignores a
precondition of a gate the hub enforces for it does not compile. The hub
cannot bind on the provider's behalf: a bind must be reachable only from a
completed authentication, and Authenticate reports who a request is, never
which credential class it presented. A device token still cannot reach a
bind; no new door was added, and every /store/* door still creates nothing.

Bind also reported success for a row the store had refused — observeLocked
logged the write failure and swallowed it — so a login could hand back a
token whose every push was then denied, with nothing in the hub explaining
why. It now propagates and the login fails honestly.

And a hub that refuses a journal write while holding no binding at all logs
that its provider is not calling the binder — the sentence that would have
ended this investigation on day one instead of day two.

Tested by driving the real binary against a hub with a managed provider
(cli_provider_e2e_test.go), which is the configuration no test in this repo
covered and the reason this shipped: every existing test used BuiltinAuth,
where the wiring happened to work. Both directions are pinned — a provider
that binds pushes, one that ignores the binder reproduces the reported
symptom exactly.


Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:23:34 -07:00

131 lines
5.8 KiB
Go

package webapp
import (
"net/http"
"strings"
)
// Authentication is opt-in (`auth` in the server config) and sits behind the
// AuthProvider interface. The open-source server ships exactly one
// implementation, BuiltinAuth (email + password accounts in a file-backed
// registry, server-owned /auth/* pages). A managed deployment can swap in a
// different provider (e.g. PropelAuth-backed) without touching the CLI or
// the API: the CLI learns the login page from /api/config and the callback
// flow is provider-agnostic.
// User is an authenticated account as the rest of the server sees it.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Admin bool `json:"admin,omitempty"` // hub admin (approve users, govern shares)
}
// AuthProvider is the seam between the server and an identity system.
type AuthProvider interface {
// CLILoginPath is the page `bdrive login` opens in a browser. The CLI
// appends ?redirect=http://127.0.0.1:<port>/callback&state=<nonce>.
CLILoginPath() string
// Authenticate resolves the request's Bearer token or session cookie.
Authenticate(r *http.Request) (User, bool)
// Register mounts the provider's own pages and endpoints (/auth/*,
// /api/auth/*) on the server mux.
Register(mux *http.ServeMux)
// Accounts lists every account the provider knows, oldest first. Startup
// tasks (the org migration) need it, and both implementations already had
// it — declaring it here stops callers reaching for a concrete type.
Accounts() []User
// UseDeviceBinder hands the provider the hub's device-binding hook. The
// provider MUST call it at every point it mints a CLI token, before the
// token is handed over, and MUST refuse the login if it returns an error.
//
// This is on the interface — a breaking change for an out-of-tree provider,
// on purpose — because it is a PRECONDITION of a gate the hub enforces for
// every provider. store.go's ownJournal refuses a journal write unless the
// device id is bound to the caller's account, and DeviceRegistry.Bind is the
// only thing that binds. That hook used to be a field on BuiltinAuth, wired
// behind `if a, ok := s.Auth.(*BuiltinAuth); ok` — so on a hub running any
// other provider nothing ever called it, no device was ever bound, and EVERY
// journal push 403'd forever while login, permissions and blob uploads all
// looked healthy. A gate whose enabler is wired to one concrete type is a
// gate that is enforced further than it can be satisfied; declaring it here
// is what makes a provider that ignores it fail to compile instead.
//
// The hub cannot do this for the provider. Binding must be reachable only
// from a completed authentication — a device token that could reach a bind
// would let a stolen credential squat a teammate's id — and Authenticate
// reports only WHO a request is, never which credential class it presented.
// Only the provider knows it is minting, so only the provider can bind.
UseDeviceBinder(bind DeviceBinder)
}
// DeviceBinder records that the device identified by the request's
// X-Bdrive-Device header belongs to email. It returns an error when the id is
// already another account's, which a provider must surface as a failed login
// rather than a token that cannot push.
type DeviceBinder func(email string, r *http.Request) error
// AccountApprover is the optional half of account administration: signup
// policy and the approval queue behind /api/admin/*. A provider whose accounts
// live in an external identity system does not implement it, and those routes
// say so (503) rather than pretending the queue is empty.
type AccountApprover interface {
PendingUsers() []User
Approve(id string) error
Deny(id string) error
SetPolicy(requireVerification, requireApproval bool) error
// Policy reports the signup gates as configured. The provider assembles
// it, so the hub never reaches into provider fields to render the page.
Policy() SignupPolicy
}
// Brander is the optional hub-name half: a provider that renders its own
// sign-in pages knows what to call this hub.
type Brander interface{ Branding() string }
// SignupPolicy is what /api/admin/policy reports: which gates are on, and
// which of them are server-config owned (read-only to a browser session, so
// that no one can widen access by clicking).
type SignupPolicy struct {
RequireVerification bool `json:"require_verification"`
RequireApproval bool `json:"require_approval"`
AllowSignup bool `json:"allow_signup"`
AllowedDomains []string `json:"allowed_domains"` // read-only
Admins []string `json:"admins"` // read-only
Mailer bool `json:"mailer"` // SMTP configured?
}
// authGate wraps the API with authentication when a provider is configured.
// The static frontend and the provider's own surface stay reachable so a
// browser can get to the login page; everything else under /api/ needs a
// valid identity.
func (s *Server) authGate(next http.Handler) http.Handler {
if s.Auth == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
open := strings.HasPrefix(p, "/auth/") ||
strings.HasPrefix(p, "/api/auth/") ||
p == "/api/config" ||
!strings.HasPrefix(p, "/api/") // static frontend; its API calls are gated
if !open {
if _, ok := s.Auth.Authenticate(r); !ok {
http.Error(w, "authentication required (bdrive login, or sign in at /auth/login)", http.StatusUnauthorized)
return
}
}
next.ServeHTTP(w, r)
})
}
// requestUser returns the authenticated user, or a zero User when auth is
// disabled (everything then runs as an anonymous single user).
func (s *Server) requestUser(r *http.Request) User {
if s.Auth == nil {
return User{}
}
u, _ := s.Auth.Authenticate(r)
return u
}