feat(auth): first account on a new server needs the setup token from the server log (M462 #4982)
test-go / test (push) Successful in 2m3s
test-web / test (push) Successful in 1m14s
test-go / integration (push) Successful in 4m38s
release / Build signed APK (releases and dev) (push) Successful in 5m36s
release / Build + push container image (push) Successful in 1m24s
release / Verify release artifacts (tag releases only) (push) Skipped

While no accounts exist, the server mints a random setup token at boot and
logs it. Registering the first account (which becomes admin) must carry it,
so whoever reaches a freshly exposed instance first cannot claim it. The
register page asks GET /api/auth/setup-status and shows a "Setup token"
field in place of the invite field while setup is pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:35:52 -04:00
co-authored by Claude Opus 5.5
parent 46194a609d
commit 2f3fbccab6
7 changed files with 276 additions and 16 deletions
+72
View File
@@ -273,3 +273,75 @@ func TestRegister_FirstAdminRace(t *testing.T) {
// not "exactly one" (which would require serializable isolation).
t.Logf("admin count after race = %d (>=1 is the invariant)", adminCount)
}
// With the gate on (as Mount sets it), the first account needs the setup
// token from the log; a missing or wrong one is refused and creates nothing.
func TestRegister_FirstUserNeedsSetupToken(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, _ := testHandlers(t)
resetUsers(t, h)
token, err := auth.NewSetupToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
h.setupToken = token
h.requireSetupToken = true
register := func(body string) int {
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleRegister(rec, req)
return rec.Code
}
if code := register(`{"username":"squatter","password":"abcd1234"}`); code != http.StatusForbidden {
t.Errorf("no token: status = %d, want 403", code)
}
if code := register(`{"username":"squatter","password":"abcd1234","setup_token":"wrong"}`); code != http.StatusForbidden {
t.Errorf("wrong token: status = %d, want 403", code)
}
var n int
if err := h.pool.QueryRow(context.Background(), `SELECT count(*) FROM users`).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Fatalf("a refused registration created %d account(s)", n)
}
if code := register(`{"username":"operator","password":"abcd1234","setup_token":"` + token.Value() + `"}`); code != http.StatusOK {
t.Fatalf("right token: status = %d, want 200", code)
}
// Once an account exists the token plays no part: the second user is
// governed by registration mode, not the setup token.
if _, err := h.pool.Exec(context.Background(),
`UPDATE registration_settings SET mode = 'open' WHERE id = true`); err != nil {
t.Fatalf("open mode: %v", err)
}
t.Cleanup(func() {
_, _ = h.pool.Exec(context.Background(),
`UPDATE registration_settings SET mode = 'invite_only' WHERE id = true`)
})
if code := register(`{"username":"second","password":"abcd1234"}`); code != http.StatusOK {
t.Errorf("second user without token: status = %d, want 200", code)
}
}
func TestSetupToken_MatchesOnlyItself(t *testing.T) {
tok, err := auth.NewSetupToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
if !tok.Matches(tok.Value()) {
t.Error("token does not match itself")
}
if tok.Matches("") || tok.Matches(tok.Value()+"x") {
t.Error("token matched something else")
}
var none *auth.SetupToken
if none.Matches("") || none.Matches("anything") {
t.Error("a nil token must fail closed")
}
}