| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "net/url" |
| 7 | "os" |
| 8 | "regexp" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | // Connected third-party services (docs/connected-services.md). |
| 15 | // |
| 16 | // The server stores one opaque, client-encrypted credential blob per (member, |
| 17 | // service) and never reads it — the same trust boundary as instance_keys. A |
| 18 | // member seals their credential to their OWN key before storing it; actually |
| 19 | // *using* it is a separate, time-boxed grant the member seals to a bot. Nothing |
| 20 | // usable ever rests on the server, so a server compromise yields ciphertext. |
| 21 | const maxConnectedBytes = 16 * 1024 |
| 22 | |
| 23 | // Service names are our own keys, never interpolated into an outbound request, |
| 24 | // but keep them to a tidy slug regardless. |
| 25 | var serviceRE = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) |
| 26 | |
| 27 | // GET /api/connected?token= -> {"services": ["dnsimple", ...]} |
| 28 | func (s *Server) listConnected(w http.ResponseWriter, r *http.Request) { |
| 29 | token := r.URL.Query().Get("token") |
| 30 | if !s.tokenOK(token) { |
| 31 | httpError(w, http.StatusForbidden, "invalid invite") |
| 32 | return |
| 33 | } |
| 34 | userID, _ := s.db.deviceTokenUser(token) |
| 35 | services, err := s.db.listConnectedServices(userID) |
| 36 | if err != nil { |
| 37 | httpError(w, http.StatusInternalServerError, "db error") |
| 38 | return |
| 39 | } |
| 40 | if services == nil { |
| 41 | services = []string{} |
| 42 | } |
| 43 | writeJSON(w, http.StatusOK, map[string]any{"services": services}) |
| 44 | } |
| 45 | |
| 46 | // GET /api/connected/{service}?token= -> {"service","blob","connected"} |
| 47 | func (s *Server) getConnected(w http.ResponseWriter, r *http.Request) { |
| 48 | token := r.URL.Query().Get("token") |
| 49 | if !s.tokenOK(token) { |
| 50 | httpError(w, http.StatusForbidden, "invalid invite") |
| 51 | return |
| 52 | } |
| 53 | service := r.PathValue("service") |
| 54 | if !serviceRE.MatchString(service) { |
| 55 | httpError(w, http.StatusBadRequest, "bad service") |
| 56 | return |
| 57 | } |
| 58 | userID, _ := s.db.deviceTokenUser(token) |
| 59 | blob, ok, err := s.db.getConnectedService(userID, service) |
| 60 | if err != nil { |
| 61 | httpError(w, http.StatusInternalServerError, "db error") |
| 62 | return |
| 63 | } |
| 64 | writeJSON(w, http.StatusOK, map[string]any{"service": service, "blob": blob, "connected": ok}) |
| 65 | } |
| 66 | |
| 67 | // PUT /api/connected/{service} body {"token","blob"} -> {"ok":true} |
| 68 | func (s *Server) putConnected(w http.ResponseWriter, r *http.Request) { |
| 69 | r.Body = http.MaxBytesReader(w, r.Body, maxConnectedBytes+4096) |
| 70 | var in struct { |
| 71 | Token string `json:"token"` |
| 72 | Blob string `json:"blob"` |
| 73 | } |
| 74 | if json.NewDecoder(r.Body).Decode(&in) != nil { |
| 75 | httpError(w, http.StatusBadRequest, "bad json") |
| 76 | return |
| 77 | } |
| 78 | if !s.tokenOK(in.Token) { |
| 79 | httpError(w, http.StatusForbidden, "invalid invite") |
| 80 | return |
| 81 | } |
| 82 | service := r.PathValue("service") |
| 83 | if !serviceRE.MatchString(service) { |
| 84 | httpError(w, http.StatusBadRequest, "bad service") |
| 85 | return |
| 86 | } |
| 87 | if in.Blob == "" || len(in.Blob) > maxConnectedBytes { |
| 88 | httpError(w, http.StatusBadRequest, "bad blob") |
| 89 | return |
| 90 | } |
| 91 | userID, _ := s.db.deviceTokenUser(in.Token) |
| 92 | if err := s.db.putConnectedService(userID, service, in.Blob); err != nil { |
| 93 | httpError(w, http.StatusInternalServerError, "db error") |
| 94 | return |
| 95 | } |
| 96 | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| 97 | } |
| 98 | |
| 99 | // DELETE /api/connected/{service}?token= -> {"ok":true} (disconnect) |
| 100 | func (s *Server) deleteConnected(w http.ResponseWriter, r *http.Request) { |
| 101 | token := r.URL.Query().Get("token") |
| 102 | if !s.tokenOK(token) { |
| 103 | httpError(w, http.StatusForbidden, "invalid invite") |
| 104 | return |
| 105 | } |
| 106 | service := r.PathValue("service") |
| 107 | if !serviceRE.MatchString(service) { |
| 108 | httpError(w, http.StatusBadRequest, "bad service") |
| 109 | return |
| 110 | } |
| 111 | userID, _ := s.db.deviceTokenUser(token) |
| 112 | if err := s.db.deleteConnectedService(userID, service); err != nil { |
| 113 | httpError(w, http.StatusInternalServerError, "db error") |
| 114 | return |
| 115 | } |
| 116 | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| 117 | } |
| 118 | |
| 119 | // brokerURL is the connect broker (docs/connected-services.md) that instances |
| 120 | // fetch OAuth config from and sidecars exchange codes through. It defaults to the |
| 121 | // shared Eleven broker, so a stock instance needs NO connect config at all; a |
| 122 | // self-hoster running their own points LCHAT_CONNECT_BROKER_URL at it. No DNSimple |
| 123 | // config ever lives on the instance — it all comes from the broker. |
| 124 | func brokerURL() string { |
| 125 | if v := strings.TrimSpace(os.Getenv("LCHAT_CONNECT_BROKER_URL")); v != "" { |
| 126 | return strings.TrimRight(v, "/") |
| 127 | } |
| 128 | return "https://connect.elevenmessenger.com" |
| 129 | } |
| 130 | |
| 131 | // The broker's public OAuth config per service (client_id, authorize_url, |
| 132 | // redirect_uri — all non-secret), cached briefly so opening the connect |
| 133 | // surface doesn't refetch each time. A service the broker has no app for |
| 134 | // caches as an explicit "absent" (the catalog then offers paste-a-token only); |
| 135 | // on a fetch failure we serve the last-known value. |
| 136 | var ( |
| 137 | oauthCfgMu sync.Mutex |
| 138 | oauthCfgVals = map[string]*oauthCfgEntry{} |
| 139 | ) |
| 140 | |
| 141 | type oauthCfgEntry struct { |
| 142 | val map[string]any // nil = the broker answered "no app for this service" |
| 143 | when time.Time |
| 144 | } |
| 145 | |
| 146 | func brokerOAuthConfig(service string) map[string]any { |
| 147 | oauthCfgMu.Lock() |
| 148 | defer oauthCfgMu.Unlock() |
| 149 | if e := oauthCfgVals[service]; e != nil && time.Since(e.when) < 10*time.Minute { |
| 150 | return e.val |
| 151 | } |
| 152 | resp, err := (&http.Client{Timeout: 8 * time.Second}).Get(brokerURL() + "/config?service=" + url.QueryEscape(service)) |
| 153 | if err == nil { |
| 154 | defer resp.Body.Close() |
| 155 | switch resp.StatusCode { |
| 156 | case http.StatusOK: |
| 157 | var cfg map[string]any |
| 158 | if json.NewDecoder(resp.Body).Decode(&cfg) == nil { |
| 159 | oauthCfgVals[service] = &oauthCfgEntry{val: cfg, when: time.Now()} |
| 160 | } |
| 161 | case http.StatusNotFound: // a definitive "not offered" — cache that too |
| 162 | oauthCfgVals[service] = &oauthCfgEntry{val: nil, when: time.Now()} |
| 163 | } |
| 164 | } |
| 165 | if e := oauthCfgVals[service]; e != nil { |
| 166 | return e.val |
| 167 | } |
| 168 | return nil |
| 169 | } |
| 170 | |
| 171 | // GET /api/oauth/config?service=github -> {"client_id","authorize_url","redirect_uri"} |
| 172 | // |
| 173 | // The browser needs the *public* OAuth params to start a flow. The instance |
| 174 | // holds none of this itself — it asks the connect broker (override with |
| 175 | // LCHAT_CONNECT_BROKER_URL) for whichever service the catalog is offering; an |
| 176 | // empty {} means "no OAuth app for this service" and the catalog falls back to |
| 177 | // paste-a-token. No secret is ever involved. |
| 178 | func (s *Server) oauthConfig(w http.ResponseWriter, r *http.Request) { |
| 179 | if !s.tokenOK(r.URL.Query().Get("token")) { |
| 180 | httpError(w, http.StatusForbidden, "invalid invite") |
| 181 | return |
| 182 | } |
| 183 | service := r.URL.Query().Get("service") |
| 184 | if !serviceRE.MatchString(service) { |
| 185 | writeJSON(w, http.StatusOK, map[string]any{}) |
| 186 | return |
| 187 | } |
| 188 | cfg := brokerOAuthConfig(service) |
| 189 | if cfg == nil { |
| 190 | cfg = map[string]any{} |
| 191 | } |
| 192 | writeJSON(w, http.StatusOK, cfg) |
| 193 | } |
| 194 | |
| 195 | // GET /oauth/{service}/callback — the registered OAuth redirect target. It runs a |
| 196 | // tiny page that hands the one-time `code` back to the app window that started |
| 197 | // the flow (which alone holds the PKCE verifier) via postMessage, then closes. |
| 198 | // It stores nothing and sees only a single-use code — safe if compromised. |
| 199 | func (s *Server) oauthCallbackPage(w http.ResponseWriter, r *http.Request) { |
| 200 | w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'") |
| 201 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 202 | w.Write([]byte(`<!doctype html><meta charset="utf-8"><title>Connecting…</title>` + |
| 203 | `<body style="font:16px system-ui;margin:2rem;color:#333">Connecting… you can close this window.` + |
| 204 | `<script src="/static/oauth-callback.js"></script>`)) |
| 205 | } |
| 206 | |