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 = "" 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 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 }