Files
keeporg/internal/server/oidc.go
T
ReactorcoremeltdownandClaude Opus 5.5 f8f634fbf7 Add an Account page for sign-in methods and signed-in devices
/web/account, offered to everyone, lists every browser and client signed
in as you, with when each was last used, and signs any of them out, or all
but the current one. It sets up, replaces and removes the second factor,
and holds the password form, moved from the vault page. Administrators can
sign a user out everywhere from the People page.

The API gains GET and DELETE /api/v1/auth/sessions and DELETE
/api/v1/auth/totp. Sessions are named by a digest of their token, never the
token, and record when they were last used. Replacing or removing an active
second factor now needs the password and a current code.

Server version 0.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:37:47 +01:00

317 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, returnURL 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 = ""
if dev.Web {
token := s.newBrowserSession(st, u.ID, r).Token
// 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 = s.newSession(st, u.ID, dev.Label).Token
returnURL = dev.ReturnURL
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.renderApproved(w, returnURL)
}
// 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
}