Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync

Why: collaborative KeePass use needs one arbiter of "who wrote last" that
never sees a master password, plus access control that follows an org chart.

What: stdlib-only Go server. Metadata is a single JSON document with one
writer and atomic commit; KDBX payloads are versioned blobs on disk. Access
comes from a group tree where a grant on a group reaches every member below
it. Login is password+TOTP, invite links, or OIDC through a browser-approved
device flow. Pushes carry If-Match and are arbitrated by a per-database merge
strategy (server-wins by default). Updates are pushed to clients over SSE.

Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and
isolation, store rollback, blob pruning, every push verdict, and an
end-to-end HTTP pass over login, conflict, force, read-only grants, invite
redemption, TOTP enrolment, device login and the event stream.
This commit is contained in:
2026-10-29 15:54:10 +01:00
commit 8bbb3179a1
26 changed files with 3897 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
)
// OIDCConfig configures single sign-on. The server acts as a confidential
// client: it exchanges the authorization code over TLS directly with the
// provider's token endpoint and then reads the identity from the userinfo
// endpoint, so no local JWT verification is needed.
type OIDCConfig struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Scopes string `json:"scopes"`
// AutoCreate provisions an account on first successful sign-in.
AutoCreate bool `json:"auto_create"`
// DefaultGroupID is the group auto-created users join.
DefaultGroupID string `json:"default_group_id"`
}
func (c OIDCConfig) Enabled() bool {
return c.Issuer != "" && c.ClientID != "" && c.ClientSecret != ""
}
type oidcMetadata struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
}
type oidcProvider struct {
cfg OIDCConfig
redirectURI string
http *http.Client
mu sync.Mutex
meta *oidcMetadata
}
func newOIDCProvider(cfg OIDCConfig, redirectURI string) *oidcProvider {
if cfg.Scopes == "" {
cfg.Scopes = "openid profile email"
}
return &oidcProvider{cfg: cfg, redirectURI: redirectURI,
http: &http.Client{Timeout: 15 * time.Second}}
}
// metadata fetches and caches the provider's discovery document.
func (p *oidcProvider) metadata() (*oidcMetadata, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.meta != nil {
return p.meta, nil
}
u := strings.TrimRight(p.cfg.Issuer, "/") + "/.well-known/openid-configuration"
resp, err := p.http.Get(u)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oidc: discovery %s: %s", u, resp.Status)
}
var m oidcMetadata
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil {
return nil, err
}
if m.AuthorizationEndpoint == "" || m.TokenEndpoint == "" || m.UserinfoEndpoint == "" {
return nil, errors.New("oidc: discovery document is missing endpoints")
}
p.meta = &m
return p.meta, nil
}
type oidcIdentity struct {
Subject string `json:"sub"`
Email string `json:"email"`
Username string `json:"preferred_username"`
Name string `json:"name"`
}
// exchange turns an authorization code into the caller's identity.
func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) {
meta, err := p.metadata()
if err != nil {
return nil, err
}
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {p.redirectURI},
}
req, err := http.NewRequest(http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(url.QueryEscape(p.cfg.ClientID), url.QueryEscape(p.cfg.ClientSecret))
resp, err := p.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
return nil, fmt.Errorf("oidc: token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var tok struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tok); err != nil {
return nil, err
}
if tok.AccessToken == "" {
return nil, errors.New("oidc: token response carried no access token")
}
return p.userinfo(meta.UserinfoEndpoint, tok.AccessToken)
}
func (p *oidcProvider) userinfo(endpoint, accessToken string) (*oidcIdentity, error) {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := p.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oidc: userinfo: %s", resp.Status)
}
var id oidcIdentity
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&id); err != nil {
return nil, err
}
if id.Subject == "" {
return nil, errors.New("oidc: userinfo carried no subject")
}
return &id, nil
}
// handleOIDCLogin starts the authorization code flow for a pending device.
func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
if s.oidc == nil {
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
return
}
meta, err := s.oidc.metadata()
if err != nil {
s.log.Printf("oidc discovery: %v", err)
s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.")
return
}
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
state := secret.Token()
if err := s.st.Update(func(st *store.State) error {
d := st.DeviceByUserCode(userCode)
if d == nil || time.Now().After(d.ExpiresAt) {
return errors.New("unknown or expired device code")
}
d.OIDCState = state
return nil
}); err != nil {
s.renderDevice(w, userCode, err.Error())
return
}
q := url.Values{
"response_type": {"code"},
"client_id": {s.oidc.cfg.ClientID},
"redirect_uri": {s.oidc.redirectURI},
"scope": {s.oidc.cfg.Scopes},
"state": {state},
}
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound)
}
// handleOIDCCallback finishes the flow and approves the waiting device.
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
if s.oidc == nil {
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
return
}
q := r.URL.Query()
if e := q.Get("error"); e != "" {
s.renderDone(w, "Sign-in failed", e)
return
}
state, code := q.Get("state"), q.Get("code")
if state == "" || code == "" {
s.renderDone(w, "Sign-in failed", "The provider did not return a code.")
return
}
// Bind the state to a pending device before spending the code.
var found bool
s.st.Read(func(st *store.State) {
for _, d := range st.Devices {
if d.OIDCState != "" && secret.Equal(d.OIDCState, state) && time.Now().Before(d.ExpiresAt) {
found = true
}
}
})
if !found {
s.renderDone(w, "Sign-in failed", "This sign-in request has expired. Start again from the client.")
return
}
id, err := s.oidc.exchange(code)
if err != nil {
s.log.Printf("oidc exchange: %v", err)
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
return
}
if err := s.st.Update(func(st *store.State) error {
var dev *store.DeviceAuth
for _, d := range st.Devices {
if d.OIDCState != "" && secret.Equal(d.OIDCState, state) {
dev = d
}
}
if dev == nil || time.Now().After(dev.ExpiresAt) {
return errors.New("this sign-in request has expired")
}
u, err := s.linkOIDCUser(st, id)
if err != nil {
return err
}
dev.OIDCState = ""
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
return nil
}); err != nil {
s.renderDone(w, "Sign-in failed", err.Error())
return
}
s.renderDone(w, "Device approved", "You can close this page and return to the application.")
}
// linkOIDCUser finds the account behind an identity, provisioning one when
// the deployment allows it.
func (s *Server) linkOIDCUser(st *store.State, id *oidcIdentity) (*store.User, error) {
issuer := strings.TrimRight(s.oidc.cfg.Issuer, "/")
if u := st.UserByOIDC(issuer, id.Subject); u != nil {
if u.Disabled {
return nil, errors.New("this account is disabled")
}
return u, nil
}
// A local account may claim the identity by matching username or email,
// which is how an invited user upgrades to single sign-on.
for _, u := range st.Users {
if u.OIDCSubject != "" {
continue
}
if (id.Username != "" && u.Username == id.Username) ||
(id.Email != "" && strings.EqualFold(u.Email, id.Email)) {
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
return u, nil
}
}
if !s.oidc.cfg.AutoCreate {
return nil, errors.New("no account is linked to this identity; redeem an invite link first")
}
name := id.Username
if name == "" {
name = id.Email
}
if name == "" {
name = "sso-" + secret.ID()
}
if validUsername(name) != nil || st.UserByName(name) != nil {
name = "sso-" + secret.ID()
}
u := &store.User{ID: secret.ID(), Username: name, Email: id.Email,
OIDCIssuer: issuer, OIDCSubject: id.Subject, CreatedAt: time.Now()}
st.Users[u.ID] = u
if g := s.oidc.cfg.DefaultGroupID; g != "" && st.Groups[g] != nil {
st.Memberships = append(st.Memberships, store.Membership{UserID: u.ID, GroupID: g, Role: store.RoleMember})
}
return u, nil
}