Files
keeporg/internal/server/oidc.go
T
root 7eed722ff5 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.
2026-10-30 10:47:51 +01:00

316 lines
9.4 KiB
Go

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
}
// 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")
}
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
}
var webToken string
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 = ""
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.")
}
// 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
}