package main import ( "encoding/json" "net/http" "net/url" "os" "regexp" "strings" "sync" "time" ) // Connected third-party services (docs/connected-services.md). // // The server stores one opaque, client-encrypted credential blob per (member, // service) and never reads it — the same trust boundary as instance_keys. A // member seals their credential to their OWN key before storing it; actually // *using* it is a separate, time-boxed grant the member seals to a bot. Nothing // usable ever rests on the server, so a server compromise yields ciphertext. const maxConnectedBytes = 16 * 1024 // Service names are our own keys, never interpolated into an outbound request, // but keep them to a tidy slug regardless. var serviceRE = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) // GET /api/connected?token= -> {"services": ["dnsimple", ...]} func (s *Server) listConnected(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if !s.tokenOK(token) { httpError(w, http.StatusForbidden, "invalid invite") return } userID, _ := s.db.deviceTokenUser(token) services, err := s.db.listConnectedServices(userID) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if services == nil { services = []string{} } writeJSON(w, http.StatusOK, map[string]any{"services": services}) } // GET /api/connected/{service}?token= -> {"service","blob","connected"} func (s *Server) getConnected(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if !s.tokenOK(token) { httpError(w, http.StatusForbidden, "invalid invite") return } service := r.PathValue("service") if !serviceRE.MatchString(service) { httpError(w, http.StatusBadRequest, "bad service") return } userID, _ := s.db.deviceTokenUser(token) blob, ok, err := s.db.getConnectedService(userID, service) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"service": service, "blob": blob, "connected": ok}) } // PUT /api/connected/{service} body {"token","blob"} -> {"ok":true} func (s *Server) putConnected(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxConnectedBytes+4096) var in struct { Token string `json:"token"` Blob string `json:"blob"` } if json.NewDecoder(r.Body).Decode(&in) != nil { httpError(w, http.StatusBadRequest, "bad json") return } if !s.tokenOK(in.Token) { httpError(w, http.StatusForbidden, "invalid invite") return } service := r.PathValue("service") if !serviceRE.MatchString(service) { httpError(w, http.StatusBadRequest, "bad service") return } if in.Blob == "" || len(in.Blob) > maxConnectedBytes { httpError(w, http.StatusBadRequest, "bad blob") return } userID, _ := s.db.deviceTokenUser(in.Token) if err := s.db.putConnectedService(userID, service, in.Blob); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // DELETE /api/connected/{service}?token= -> {"ok":true} (disconnect) func (s *Server) deleteConnected(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if !s.tokenOK(token) { httpError(w, http.StatusForbidden, "invalid invite") return } service := r.PathValue("service") if !serviceRE.MatchString(service) { httpError(w, http.StatusBadRequest, "bad service") return } userID, _ := s.db.deviceTokenUser(token) if err := s.db.deleteConnectedService(userID, service); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // brokerURL is the connect broker (docs/connected-services.md) that instances // fetch OAuth config from and sidecars exchange codes through. It defaults to the // shared Eleven broker, so a stock instance needs NO connect config at all; a // self-hoster running their own points LCHAT_CONNECT_BROKER_URL at it. No DNSimple // config ever lives on the instance — it all comes from the broker. func brokerURL() string { if v := strings.TrimSpace(os.Getenv("LCHAT_CONNECT_BROKER_URL")); v != "" { return strings.TrimRight(v, "/") } return "https://connect.elevenmessenger.com" } // The broker's public OAuth config per service (client_id, authorize_url, // redirect_uri — all non-secret), cached briefly so opening the connect // surface doesn't refetch each time. A service the broker has no app for // caches as an explicit "absent" (the catalog then offers paste-a-token only); // on a fetch failure we serve the last-known value. var ( oauthCfgMu sync.Mutex oauthCfgVals = map[string]*oauthCfgEntry{} ) type oauthCfgEntry struct { val map[string]any // nil = the broker answered "no app for this service" when time.Time } func brokerOAuthConfig(service string) map[string]any { oauthCfgMu.Lock() defer oauthCfgMu.Unlock() if e := oauthCfgVals[service]; e != nil && time.Since(e.when) < 10*time.Minute { return e.val } resp, err := (&http.Client{Timeout: 8 * time.Second}).Get(brokerURL() + "/config?service=" + url.QueryEscape(service)) if err == nil { defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: var cfg map[string]any if json.NewDecoder(resp.Body).Decode(&cfg) == nil { oauthCfgVals[service] = &oauthCfgEntry{val: cfg, when: time.Now()} } case http.StatusNotFound: // a definitive "not offered" — cache that too oauthCfgVals[service] = &oauthCfgEntry{val: nil, when: time.Now()} } } if e := oauthCfgVals[service]; e != nil { return e.val } return nil } // GET /api/oauth/config?service=github -> {"client_id","authorize_url","redirect_uri"} // // The browser needs the *public* OAuth params to start a flow. The instance // holds none of this itself — it asks the connect broker (override with // LCHAT_CONNECT_BROKER_URL) for whichever service the catalog is offering; an // empty {} means "no OAuth app for this service" and the catalog falls back to // paste-a-token. No secret is ever involved. func (s *Server) oauthConfig(w http.ResponseWriter, r *http.Request) { if !s.tokenOK(r.URL.Query().Get("token")) { httpError(w, http.StatusForbidden, "invalid invite") return } service := r.URL.Query().Get("service") if !serviceRE.MatchString(service) { writeJSON(w, http.StatusOK, map[string]any{}) return } cfg := brokerOAuthConfig(service) if cfg == nil { cfg = map[string]any{} } writeJSON(w, http.StatusOK, cfg) } // GET /oauth/{service}/callback — the registered OAuth redirect target. It runs a // tiny page that hands the one-time `code` back to the app window that started // the flow (which alone holds the PKCE verifier) via postMessage, then closes. // It stores nothing and sees only a single-use code — safe if compromised. func (s *Server) oauthCallbackPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`Connecting…` + `Connecting… you can close this window.` + ``)) }