Files
beardrive/internal/webapp/auth_test.go
Snow LeeandClaude Opus 4.8 bd7ea16200 feat(web): invite-only signup by default; harden signup postures
A hub on a public URL could be signed up to with any fake email. Make the
safe posture the default and turn the dangerous ones into startup errors.

Three supported postures:
- invite-only (NEW default): allow_signup defaults false. A valid org
  invite link bootstraps an account even when self-signup is closed — the
  only way in. pageLogin/pageSignup detect a /join/<token> target via
  BuiltinAuth.InviteValid (wired to OrgDB.ValidInvite) and offer account
  creation; signupInvited skips the domain/approval/verification gates and
  activates immediately (the owner's invite is the vetting).
- approval-gated: allow_signup + require_approval (no SMTP needed).
- domain-restricted + verified: allow_signup + allowed_domains +
  require_verification (needs smtp).

Hardening:
- BuiltinAuth.ValidateSignupPolicy (run at hub startup in web.go) refuses to
  boot an open hub with no gate, or require_verification without a mailer
  (the link would otherwise only reach the server log).
- handleAdminPolicy rejects enabling verification without SMTP; the UI
  toggle is disabled with clearer copy in that case.

Tests: TestValidateSignupPolicy, TestInviteBootstrapsAccountWhenSignupClosed,
TestPolicyVerificationNeedsMailer. Verified end to end in the running hub
(outside-domain invitee onboards via a link; direct signup stays closed).
Docs updated (README, CLAUDE.md, SKILL.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
2026-07-09 16:57:49 -07:00

410 lines
14 KiB
Go

package webapp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/internal/remote"
)
// authHub builds an auth-enabled hub server with one project.
func authHub(t *testing.T, allowSignup bool) (*Server, *BuiltinAuth, Project) {
t.Helper()
auth, err := OpenBuiltinAuth(filepath.Join(t.TempDir(), "auth.json"), allowSignup, nil)
if err != nil {
t.Fatal(err)
}
srv, p, _ := newHub(t, true, nil)
srv.Auth = auth
return srv, auth, p
}
// signupAndSession creates an account through the real signup page and
// returns its session cookie.
func signupAndSession(t *testing.T, h http.Handler, email, name, pass string) *http.Cookie {
t.Helper()
form := url.Values{"email": {email}, "name": {name}, "password": {pass}}
req := httptest.NewRequest("POST", "/auth/signup", 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 {
t.Fatalf("signup: %d %s", rec.Code, rec.Body)
}
for _, c := range rec.Result().Cookies() {
if c.Name == sessionCookie {
return c
}
}
t.Fatal("signup set no session cookie")
return nil
}
func TestAuthGatesAPI(t *testing.T) {
srv, auth, p := authHub(t, true)
h := srv.Handler()
// open surface: config, auth pages, static frontend
if rec := do(t, h, "GET", "/api/config", nil); rec.Code != 200 ||
!strings.Contains(rec.Body.String(), `"enabled":true`) ||
!strings.Contains(rec.Body.String(), "/auth/cli") {
t.Fatalf("config must stay open and advertise auth: %d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "GET", "/auth/login", nil); rec.Code != 200 {
t.Fatalf("login page: %d", rec.Code)
}
if rec := do(t, h, "GET", "/", nil); rec.Code != 200 {
t.Fatalf("frontend: %d", rec.Code)
}
// gated surface
for _, u := range []string{"/api/projects", "/api/p/" + p.ID + "/tree", "/api/p/" + p.ID + "/store/list?prefix=journal/"} {
if rec := do(t, h, "GET", u, nil); rec.Code != http.StatusUnauthorized {
t.Errorf("GET %s without auth: %d, want 401", u, rec.Code)
}
}
// a valid Bearer token opens it
cookie := signupAndSession(t, h, "a@x.io", "Alice", "password1")
_ = cookie
u := auth.users // reach in for the user id
var uid string
for id := range u {
uid = id
}
tok, err := auth.issueToken(uid, "test-device")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("GET", "/api/projects", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("with token: %d %s", rec.Code, rec.Body)
}
// a session cookie works too (browser)
req = httptest.NewRequest("GET", "/api/projects", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("with cookie: %d %s", rec.Code, rec.Body)
}
// garbage token stays out
req = httptest.NewRequest("GET", "/api/projects", nil)
req.Header.Set("Authorization", "Bearer bdt_nope")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad token: %d, want 401", rec.Code)
}
}
func TestLoginWrongPassword(t *testing.T) {
srv, _, _ := authHub(t, true)
h := srv.Handler()
signupAndSession(t, h, "a@x.io", "Alice", "password1")
form := url.Values{"email": {"a@x.io"}, "password": {"wrong-pass"}}
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Wrong email or password") {
t.Fatalf("wrong password: %d", rec.Code)
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("wrong password must not create a session")
}
}
func TestSignupDisabled(t *testing.T) {
srv, auth, _ := authHub(t, false)
h := srv.Handler()
form := url.Values{"email": {"a@x.io"}, "name": {"A"}, "password": {"password1"}}
req := httptest.NewRequest("POST", "/auth/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "invite-only") {
t.Fatalf("signup while disabled: %d %s", rec.Code, rec.Body)
}
if len(auth.users) != 0 {
t.Fatal("account created despite allow_signup=false")
}
}
// The browser flow bdrive login drives: session → /auth/cli redirect with a
// one-time code → exchange for a device token.
func TestCLICallbackFlow(t *testing.T) {
srv, _, p := authHub(t, true)
h := srv.Handler()
cookie := signupAndSession(t, h, "cli@x.io", "CLI", "password1")
// non-loopback redirect is refused
req := httptest.NewRequest("GET", "/auth/cli?redirect="+url.QueryEscape("http://evil.example/cb")+"&state=s", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("non-loopback redirect: %d, want 400", rec.Code)
}
// without a session, /auth/cli sends the browser to the login page
req = httptest.NewRequest("GET", "/auth/cli?redirect="+url.QueryEscape("http://127.0.0.1:9999/callback")+"&state=s1", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "/auth/login") {
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)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("cli redirect: %d", rec.Code)
}
loc, err := url.Parse(rec.Header().Get("Location"))
if err != nil || loc.Host != "127.0.0.1:9999" || loc.Query().Get("state") != "s1" {
t.Fatalf("callback location = %v", rec.Header().Get("Location"))
}
code := loc.Query().Get("code")
// exchange the code for a token
rec = do(t, h, "POST", "/api/auth/exchange", map[string]string{"code": code, "device": "laptop"})
if rec.Code != 200 {
t.Fatalf("exchange: %d %s", rec.Code, rec.Body)
}
var out struct {
Token string `json:"token"`
User User `json:"user"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Token == "" || out.User.Email != "cli@x.io" {
t.Fatalf("exchange = %+v", out)
}
// the code is single-use
if rec := do(t, h, "POST", "/api/auth/exchange", map[string]string{"code": code}); rec.Code != http.StatusUnauthorized {
t.Fatalf("code reuse: %d, want 401", rec.Code)
}
// the token opens the API
req = httptest.NewRequest("GET", "/api/p/"+p.ID+"/tree", nil)
req.Header.Set("Authorization", "Bearer "+out.Token)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("token on api: %d", rec.Code)
}
}
func TestDeviceCodeFlow(t *testing.T) {
srv, _, _ := authHub(t, true)
h := srv.Handler()
cookie := signupAndSession(t, h, "dev@x.io", "Dev", "password1")
rec := do(t, h, "POST", "/api/auth/device/start", map[string]string{"device": "server-1"})
if rec.Code != 200 {
t.Fatalf("start: %d %s", rec.Code, rec.Body)
}
var start struct {
Code string `json:"code"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &start); err != nil || start.Code == "" {
t.Fatalf("start = %s (%v)", rec.Body, err)
}
// pending until approved
rec = do(t, h, "POST", "/api/auth/device/poll", map[string]string{"code": start.Code})
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "pending") {
t.Fatalf("poll before approve: %d %s", rec.Code, rec.Body)
}
// approve from a signed-in browser
form := url.Values{"code": {start.Code}}
req := httptest.NewRequest("POST", "/auth/device", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Device connected") {
t.Fatalf("approve: %d %s", rec.Code, rec.Body)
}
rec = do(t, h, "POST", "/api/auth/device/poll", map[string]string{"code": start.Code, "device": "server-1"})
var out struct {
Token string `json:"token"`
User User `json:"user"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.Token == "" {
t.Fatalf("poll after approve: %d %s", rec.Code, rec.Body)
}
if out.User.Email != "dev@x.io" {
t.Fatalf("user = %+v", out.User)
}
// wrong code 401s
if rec := do(t, h, "POST", "/api/auth/device/poll", map[string]string{"code": "nope"}); rec.Code != http.StatusUnauthorized {
t.Fatalf("bad code: %d, want 401", rec.Code)
}
}
// Reset without SMTP: the link is logged for the admin; the token itself
// must update the password exactly once.
func TestPasswordReset(t *testing.T) {
srv, auth, _ := authHub(t, true)
h := srv.Handler()
signupAndSession(t, h, "r@x.io", "R", "oldpassword")
form := url.Values{"email": {"r@x.io"}}
req := httptest.NewRequest("POST", "/auth/reset", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "reset link is on its way") {
t.Fatalf("reset request: %d", rec.Code)
}
// grab the pending reset token (in production it arrives by email/log)
var tok string
auth.mu.Lock()
for id, g := range auth.pending {
if g.kind == "reset" {
tok = id
}
}
auth.mu.Unlock()
if tok == "" {
t.Fatal("no reset token minted")
}
form = url.Values{"token": {tok}, "password": {"newpassword9"}}
req = httptest.NewRequest("POST", "/auth/reset/confirm", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "password is updated") {
t.Fatalf("reset confirm: %d %s", rec.Code, rec.Body)
}
if auth.verifyPassword("r@x.io", "oldpassword") != nil {
t.Fatal("old password still works")
}
if auth.verifyPassword("r@x.io", "newpassword9") == nil {
t.Fatal("new password does not work")
}
// token is single-use
req = httptest.NewRequest("POST", "/auth/reset/confirm", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "invalid or expired") {
t.Fatal("reset token must be single-use")
}
// unknown emails get the same neutral answer (no account probing)
form = url.Values{"email": {"ghost@x.io"}}
req = httptest.NewRequest("POST", "/auth/reset", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "reset link is on its way") {
t.Fatalf("reset for unknown email must look identical: %d", rec.Code)
}
}
// Accounts and tokens survive a server restart; revocation sticks.
func TestAuthPersistence(t *testing.T) {
path := filepath.Join(t.TempDir(), "auth.json")
a1, err := OpenBuiltinAuth(path, true, nil)
if err != nil {
t.Fatal(err)
}
u, err := a1.signup("p@x.io", "P", "password1")
if err != nil {
t.Fatal(err)
}
tok, err := a1.issueToken(u.ID, "laptop")
if err != nil {
t.Fatal(err)
}
a2, err := OpenBuiltinAuth(path, true, nil) // "restart"
if err != nil {
t.Fatal(err)
}
if got, ok := a2.userForToken(tok); !ok || got.Email != "p@x.io" {
t.Fatalf("token after reload = %+v %v", got, ok)
}
if a2.verifyPassword("p@x.io", "password1") == nil {
t.Fatal("password lost across reload")
}
a2.revokeToken(tok)
a3, err := OpenBuiltinAuth(path, true, nil)
if err != nil {
t.Fatal(err)
}
if _, ok := a3.userForToken(tok); ok {
t.Fatal("revoked token still valid after reload")
}
// the auth file must never contain the plaintext token or password
data, _ := readFileString(path)
if strings.Contains(data, tok) || strings.Contains(data, "password1") {
t.Fatal("auth.json leaks a plaintext credential")
}
}
// A syncing device authenticates the same way: token via BDRIVE_TOKEN.
func TestSyncBackendWithToken(t *testing.T) {
srv, auth, p := authHub(t, true)
u, err := auth.signup("s@x.io", "S", "password1")
if err != nil {
t.Fatal(err)
}
tok, err := auth.issueToken(u.ID, "sync-box")
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
// without a token the backend is rejected
t.Setenv("BDRIVE_TOKEN", "")
t.Setenv("BDRIVE_HOME", t.TempDir()) // no settings.json token either
be, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID)
if err != nil {
t.Fatal(err)
}
if _, err := be.List(context.Background(), "journal/"); err == nil || !strings.Contains(err.Error(), "401") {
t.Fatalf("unauthenticated list = %v, want 401", err)
}
be.Close()
// with the token everything works
t.Setenv("BDRIVE_TOKEN", tok)
be, err = remote.Open(context.Background(), ts.URL+"/p/"+p.ID)
if err != nil {
t.Fatal(err)
}
defer be.Close()
if _, err := be.List(context.Background(), "journal/"); err != nil {
t.Fatalf("authenticated list: %v", err)
}
content := "authed content"
if err := be.Put(context.Background(), "blobs/"+shaOf(content), strings.NewReader(content), int64(len(content))); err != nil {
t.Fatalf("authenticated put: %v", err)
}
}
func readFileString(path string) (string, error) {
data, err := os.ReadFile(path)
return string(data), err
}