The Android app kept a pending browser sign-in in a thread inside the login screen. Android often kills the process while the browser is in front, so the approval reached the server but nothing collected it, and the app came back signed out with nothing saved. The pending device code is now kept in Prefs and collected whenever the login screen resumes. The app passes return_url=keeporg://device-approved when it starts the sign-in, and the server's approval page, after a password or single sign-on, links back to it. Only the keeporg: scheme is accepted. Against a server older than 0.9, which rejects the field, the app retries without it. Verified on an Android 15 emulator: approval collected after the process was killed, and the fallback against the 0.8 server. Server 0.9.0, Android 0.3.0 (versionCode 3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
9.4 KiB
Go
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 = ""
|
|
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
|
|
}
|