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
+24 -1
View File
@@ -165,9 +165,18 @@ func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.")
return
}
// With a user code this approves a waiting client; without one it signs
// a browser in to the web interface.
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
state := secret.Token()
if err := s.st.Update(func(st *store.State) error {
if userCode == "" {
st.Devices[state] = &store.DeviceAuth{
DeviceCode: state, Web: true, Label: "web", OIDCState: state,
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL),
}
return nil
}
d := st.DeviceByUserCode(userCode)
if d == nil || time.Now().After(d.ExpiresAt) {
return errors.New("unknown or expired device code")
@@ -223,6 +232,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
return
}
var webToken string
if err := s.st.Update(func(st *store.State) error {
var dev *store.DeviceAuth
for _, d := range st.Devices {
@@ -238,12 +248,25 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
return err
}
dev.OIDCState = ""
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
token := s.newSession(st, u.ID, dev.Label).Token
if dev.Web {
// Nothing is waiting to poll for this one; the browser in front
// of us is the client.
webToken = token
delete(st.Devices, dev.DeviceCode)
return nil
}
dev.SessionToken = token
return nil
}); err != nil {
s.renderDone(w, "Sign-in failed", err.Error())
return
}
if webToken != "" {
s.setWebCookie(w, webToken, s.cfg.SessionTTL)
http.Redirect(w, r, "/web/vaults", http.StatusFound)
return
}
s.renderDone(w, "Device approved", "You can close this page and return to the application.")
}