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
+71 -6
View File
@@ -242,6 +242,39 @@ func printStatus(st []client.Status) {
w.Flush()
}
func cmdPasswd(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("passwd", flag.ExitOnError)
path := configFlag(fs)
_ = fs.Parse(args)
cfg, api, err := session(*path)
if err != nil {
return err
}
fmt.Printf("Changing the account password for %s on %s.\n", cfg.Username, cfg.ServerURL)
fmt.Println("This is not any vault's password; vault passwords are set in KeePass.")
current, err := readPassword("Current password: ")
if err != nil {
return err
}
next, err := readNewAccountPassword()
if err != nil {
return err
}
err = api.ChangePassword(ctx, current, next, "")
if client.Code(err) == "totp_required" || client.Code(err) == "totp_invalid" {
code, cerr := prompt("One-time code: ")
if cerr != nil {
return cerr
}
err = api.ChangePassword(ctx, current, next, code)
}
if err != nil {
return err
}
fmt.Println("changed; this account's other sessions were signed out")
return nil
}
func cmdTOTP(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("totp", flag.ExitOnError)
path := configFlag(fs)
@@ -566,14 +599,19 @@ func cmdUsers(ctx context.Context, args []string) error {
// ---- terminal helpers ----
// stdin is read through one buffered reader for the whole process. A
// reader per prompt would discard whatever it had buffered past the first
// line, which loses typed-ahead input and breaks piped input entirely.
var stdin = bufio.NewReader(os.Stdin)
func prompt(label string) (string, error) {
fmt.Print(label)
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
line, err := stdin.ReadString('\n')
return strings.TrimSpace(line), err
}
// readPassword takes a password without echoing it. It prefers the
// environment and a file so that scripts never need a terminal at all.
// readPassword takes the account's existing password without echoing it. It
// prefers the environment and a file so that scripts never need a terminal.
func readPassword(label string) (string, error) {
if v := os.Getenv("KEEPORG_PASSWORD"); v != "" {
return v, nil
@@ -582,27 +620,54 @@ func readPassword(label string) (string, error) {
b, err := os.ReadFile(f)
return strings.TrimSpace(string(b)), err
}
return readNewSecret(label)
}
// readNewSecret asks for a secret that is being set rather than proven, so
// it never reads KEEPORG_PASSWORD: that variable holds the password you are
// signing in with, and taking it here would quietly set the new password to
// the old one.
func readNewSecret(label string) (string, error) {
restore, hidden := hideInput()
defer restore()
fmt.Print(label)
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
line, err := stdin.ReadString('\n')
if hidden {
fmt.Println()
}
return strings.TrimSpace(line), err
}
// readNewAccountPassword asks twice for an account password.
func readNewAccountPassword() (string, error) {
first, err := readNewSecret("New password: ")
if err != nil {
return "", err
}
if len(first) < 10 {
return "", errors.New("an account password must be at least 10 characters")
}
again, err := readNewSecret("Repeat: ")
if err != nil {
return "", err
}
if first != again {
return "", errors.New("the two passwords do not match")
}
return first, nil
}
// readNewPassword asks twice, so a vault is never created with a password
// its owner cannot reproduce.
func readNewPassword(label string) (string, error) {
first, err := readPassword(label)
first, err := readNewSecret(label)
if err != nil {
return "", err
}
if len(first) < 12 {
return "", errors.New("a vault password must be at least 12 characters")
}
again, err := readPassword("Repeat: ")
again, err := readNewSecret("Repeat: ")
if err != nil {
return "", err
}
+1
View File
@@ -33,6 +33,7 @@ func main() {
{"run", "keep the mirror in sync until stopped", cmdRun},
{"sync", "reconcile once and exit", cmdSync},
{"status", "show each mirrored database", cmdStatus},
{"passwd", "change your account password", cmdPasswd},
{"totp", "enrol a second factor for this account", cmdTOTP},
{"whoami", "show the signed-in account and its groups", cmdWhoami},
{"groups", "list groups you can see", cmdGroups},