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.
316 lines
9.4 KiB
Go
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
|
|
}
|