Fix single sign-on from the web, and make passwords changeable

Why: two questions about operating this turned up two gaps. "Where do I get
the admin password" had no answer once the line scrolled out of the log,
and "how do I set up OIDC" ran straight into a sign-in link that could not
work.

What:
- /oidc/login with no parameters now signs a browser in and ends in a
  session cookie. It previously required the user code of a waiting client,
  so the link on the web sign-in page always failed. With a code it still
  approves a device, and the two paths no longer overlap: an empty user
  code matches no pending device.
- POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults.
  Changing a password requires the current one, and the one-time code when
  a second factor is enrolled, and revokes that account's other sessions.
  Until now nothing could change a password, which made the advice printed
  at first start impossible to follow.
- keeporgd -reset-admin, for when that first password was not kept: a new
  one printed once, any second factor cleared, sessions revoked. It writes
  the state document directly and says to stop the server first.
- The client read stdin through a new buffered reader per prompt, so the
  first prompt swallowed the rest and anything asking twice could not be
  scripted. One shared reader now. A prompt for a password being set no
  longer reads $KEEPORG_PASSWORD either; that variable holds the password
  you sign in with, and taking it here would have set the new password to
  the old one.
- HANDOVER gains a first-sign-in section and an OIDC setup section covering
  the redirect URI, the config block, both entry points and how an identity
  becomes an account.

Proved: OIDC now has tests, against a stub provider that serves discovery,
authorization, token and userinfo. They cover the web sign-in that was
broken, device approval, linking an existing account by username, the
refusal when auto_create is off, and a forged callback state. Eight more
cover the password change. Checked by hand with the real binaries: the
first-start password logs once and works, -reset-admin replaces it while
the old one stops working, and `keeporg passwd` changes it from a pipe.
Server 0.5.0, desktop clients 0.3.0.
This commit is contained in:
2026-10-30 10:47:51 +01:00
parent 0848c0186c
commit 7eed722ff5
18 changed files with 828 additions and 28 deletions
+83 -1
View File
@@ -90,7 +90,11 @@ func main() {
showVer = flag.Bool("version", false, "print the version and exit")
healthOnly = flag.Bool("healthcheck", false,
"probe a running server on this address and exit 0 if it is healthy")
writeCfg = flag.String("write-config", "", "write a commented example config to this path and exit")
writeCfg = flag.String("write-config", "",
"write a commented example config to this path and exit")
resetAdmin = flag.Bool("reset-admin", false,
"reset the built-in administrator's password, print a new one and "+
"exit; stop the server first")
)
flag.Parse()
@@ -139,6 +143,13 @@ func main() {
return
}
if *resetAdmin {
if err := resetAdministrator(cfg, lg); err != nil {
lg.Fatalf("reset-admin: %v", err)
}
return
}
srv, err := server.New(cfg, lg)
if err != nil {
lg.Fatalf("start: %v", err)
@@ -225,6 +236,77 @@ func bootstrap(srv *server.Server, lg *log.Logger) error {
return nil
}
// resetAdministrator gives the server's administrator a new password, for
// when the one printed at first start was not kept. It writes the state
// document directly, so the server must be stopped: a running one holds the
// state in memory and would write over this on its next change.
//
// Any second factor on that account is cleared too. Someone who has lost
// the password has often lost the authenticator with it, and a recovery
// that leaves you locked out is not one.
func resetAdministrator(cfg server.Config, lg *log.Logger) error {
cfg.Defaults()
path := filepath.Join(cfg.DataDir, "state.json")
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("no server state at %s: %w", path, err)
}
st, err := store.Open(path)
if err != nil {
return err
}
password := os.Getenv("KEEPORG_ADMIN_PASSWORD")
generated := false
if password == "" {
password = secret.Token()[:20]
generated = true
}
hash, err := secret.Hash(password)
if err != nil {
return err
}
var name string
if err := st.Update(func(s *store.State) error {
u := s.UserByName("admin")
if u == nil {
// Whichever account can administer the server will do.
for _, candidate := range s.Users {
if candidate.ServerAdmin {
u = candidate
break
}
}
}
if u == nil {
u = &store.User{ID: secret.ID(), Username: "admin", CreatedAt: time.Now()}
s.Users[u.ID] = u
}
u.PassHash = hash
u.TOTPSecret, u.TOTPConfirmed = "", false
u.ServerAdmin, u.Disabled = true, false
for token, sess := range s.Sessions {
if sess.UserID == u.ID {
delete(s.Sessions, token)
}
}
name = u.Username
return nil
}); err != nil {
return err
}
if generated {
lg.Printf("reset %q: password %q (shown once). Any second factor on that "+
"account was cleared and its sessions were revoked.", name, password)
} else {
lg.Printf("reset %q to the password from KEEPORG_ADMIN_PASSWORD. Any second "+
"factor on that account was cleared and its sessions were revoked.", name)
}
lg.Print("if keeporgd was running while this ran, restart it now so it reloads the change")
return nil
}
// healthcheck asks a server listening on this configuration whether it is
// serving, and says why not when it is not.
func healthcheck(cfg server.Config) error {